diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles deleted file mode 100644 index d9177bebb5..0000000000 --- a/.eslintignore.errorfiles +++ /dev/null @@ -1,16 +0,0 @@ -# autogenerated file: run scripts/generate-eslint-error-ignore-file to update. - -src/Markdown.js -src/NodeAnimator.js -src/components/structures/RoomDirectory.js -src/components/views/rooms/MemberList.js -src/ratelimitedfunc.js -src/utils/DMRoomMap.js -src/utils/MultiInviter.js -test/components/structures/MessagePanel-test.js -test/components/views/dialogs/InteractiveAuthDialog-test.js -test/mock-clock.js -src/component-index.js -test/end-to-end-tests/node_modules/ -test/end-to-end-tests/element/ -test/end-to-end-tests/synapse/ diff --git a/.eslintrc.js b/.eslintrc.js index 9ae51f9bc5..9d68942228 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,7 +1,9 @@ module.exports = { - extends: ["matrix-org", "matrix-org/react-legacy"], - parser: "babel-eslint", - + plugins: ["matrix-org"], + extends: [ + "plugin:matrix-org/babel", + "plugin:matrix-org/react", + ], env: { browser: true, node: true, @@ -15,35 +17,65 @@ module.exports = { "prefer-promise-reject-errors": "off", "no-async-promise-executor": "off", "quotes": "off", - }, + "no-extra-boolean-cast": "off", + // Bind or arrow functions in props causes performance issues (but we + // currently use them in some places). + // It's disabled here, but we should using it sparingly. + "react/jsx-no-bind": "off", + "react/jsx-key": ["error"], + + "no-restricted-properties": [ + "error", + ...buildRestrictedPropertiesOptions( + ["window.innerHeight", "window.innerWidth", "window.visualViewport"], + "Use UIStore to access window dimensions instead.", + ), + ...buildRestrictedPropertiesOptions( + ["*.mxcUrlToHttp", "*.getHttpUriForMxc"], + "Use Media helper instead to centralise access for customisation.", + ), + ], + }, overrides: [{ - "files": ["src/**/*.{ts,tsx}"], - "extends": ["matrix-org/ts"], - "rules": { + files: [ + "src/**/*.{ts,tsx}", + "test/**/*.{ts,tsx}", + ], + extends: [ + "plugin:matrix-org/typescript", + "plugin:matrix-org/react", + ], + rules: { + // Things we do that break the ideal style + "prefer-promise-reject-errors": "off", + "quotes": "off", + "no-extra-boolean-cast": "off", + + // Remove Babel things manually due to override limitations + "@babel/no-invalid-this": ["off"], + // We're okay being explicit at the moment "@typescript-eslint/no-empty-interface": "off", // We disable this while we're transitioning "@typescript-eslint/no-explicit-any": "off", // We'd rather not do this but we do "@typescript-eslint/ban-ts-comment": "off", - - "quotes": "off", - "no-extra-boolean-cast": "off", - "no-restricted-properties": [ - "error", - ...buildRestrictedPropertiesOptions( - ["window.innerHeight", "window.innerWidth", "window.visualViewport"], - "Use UIStore to access window dimensions instead", - ), - ], }, }], + settings: { + react: { + version: "detect", + } + } }; function buildRestrictedPropertiesOptions(properties, message) { return properties.map(prop => { - const [object, property] = prop.split("."); + let [object, property] = prop.split("."); + if (object === "*") { + object = undefined; + } return { object, property, diff --git a/.flowconfig b/.flowconfig deleted file mode 100644 index 81770c6585..0000000000 --- a/.flowconfig +++ /dev/null @@ -1,6 +0,0 @@ -[include] -src/**/*.js -test/**/*.js - -[ignore] -node_modules/ diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..2c068fff33 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @matrix-org/element-web diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..e9ede862d2 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ + + + + + diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml new file mode 100644 index 0000000000..4f9826391a --- /dev/null +++ b/.github/workflows/develop.yml @@ -0,0 +1,46 @@ +name: Develop +on: + # These tests won't work for non-develop branches at the moment as they + # won't pull in the right versions of other repos, so they're only enabled + # on develop. + push: + branches: [develop] + pull_request: + branches: [develop] +jobs: + end-to-end: + runs-on: ubuntu-latest + env: + PR_NUMBER: ${{github.event.number}} + container: vectorim/element-web-ci-e2etests-env:latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Prepare End-to-End tests + run: ./scripts/ci/prepare-end-to-end-tests.sh + - name: Run End-to-End tests + run: ./scripts/ci/run-end-to-end-tests.sh + - name: Archive logs + uses: actions/upload-artifact@v2 + if: ${{ always() }} + with: + path: | + test/end-to-end-tests/logs/**/* + test/end-to-end-tests/synapse/installations/consent/homeserver.log + retention-days: 14 + - name: Download previous benchmark data + uses: actions/cache@v1 + with: + path: ./cache + key: ${{ runner.os }}-benchmark + - name: Store benchmark result + uses: matrix-org/github-action-benchmark@jsperfentry-1 + with: + tool: 'jsperformanceentry' + output-file-path: test/end-to-end-tests/performance-entries.json + fail-on-alert: false + comment-on-alert: false + # Only temporary to monitor where failures occur + alert-comment-cc-users: '@gsouquet' + github-token: ${{ secrets.DEPLOY_GH_PAGES }} + auto-push: ${{ github.ref == 'refs/heads/develop' }} diff --git a/.github/workflows/layered-build.yaml b/.github/workflows/layered-build.yaml new file mode 100644 index 0000000000..c8717667d7 --- /dev/null +++ b/.github/workflows/layered-build.yaml @@ -0,0 +1,33 @@ +name: Layered Preview Build +on: + pull_request: + branches: [develop] +jobs: + build: + runs-on: ubuntu-latest + env: + PR_NUMBER: ${{github.event.number}} + steps: + - uses: actions/checkout@v2 + - name: Build + run: scripts/ci/layered.sh && cd element-web && cp element.io/develop/config.json config.json && CI_PACKAGE=true yarn build + - name: Upload Artifact + uses: actions/upload-artifact@v2 + with: + name: previewbuild + path: element-web/webapp + # We'll only use this in a triggered job, then we're done with it + retention-days: 1 + - uses: actions/github-script@v3.1.0 + with: + script: | + var fs = require('fs'); + fs.writeFileSync('${{github.workspace}}/pr.json', JSON.stringify(context.payload.pull_request)); + - name: Upload PR Info + uses: actions/upload-artifact@v2 + with: + name: pr.json + path: pr.json + # We'll only use this in a triggered job, then we're done with it + retention-days: 1 + diff --git a/.github/workflows/netlify.yaml b/.github/workflows/netlify.yaml new file mode 100644 index 0000000000..a6a408bdbd --- /dev/null +++ b/.github/workflows/netlify.yaml @@ -0,0 +1,80 @@ +name: Upload Preview Build to Netlify +on: + workflow_run: + workflows: ["Layered Preview Build"] + types: + - completed +jobs: + build: + runs-on: ubuntu-latest + if: > + ${{ github.event.workflow_run.conclusion == 'success' }} + steps: + # There's a 'download artifact' action but it hasn't been updated for the + # workflow_run action (https://github.com/actions/download-artifact/issues/60) + # so instead we get this mess: + - name: 'Download artifact' + uses: actions/github-script@v3.1.0 + with: + script: | + var artifacts = await github.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: ${{github.event.workflow_run.id }}, + }); + var matchArtifact = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "previewbuild" + })[0]; + var download = await github.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: matchArtifact.id, + archive_format: 'zip', + }); + var fs = require('fs'); + fs.writeFileSync('${{github.workspace}}/previewbuild.zip', Buffer.from(download.data)); + + var prInfoArtifact = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "pr.json" + })[0]; + var download = await github.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: prInfoArtifact.id, + archive_format: 'zip', + }); + var fs = require('fs'); + fs.writeFileSync('${{github.workspace}}/pr.json.zip', Buffer.from(download.data)); + - name: Extract Artifacts + run: unzip -d webapp previewbuild.zip && rm previewbuild.zip && unzip pr.json.zip && rm pr.json.zip + - name: 'Read PR Info' + id: readctx + uses: actions/github-script@v3.1.0 + with: + script: | + var fs = require('fs'); + var pr = JSON.parse(fs.readFileSync('${{github.workspace}}/pr.json')); + console.log(`::set-output name=prnumber::${pr.number}`); + - name: Deploy to Netlify + id: netlify + uses: nwtgck/actions-netlify@v1.2 + with: + publish-dir: webapp + deploy-message: "Deploy from GitHub Actions" + # These don't work because we're in workflow_run + enable-pull-request-comment: false + enable-commit-comment: false + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + timeout-minutes: 1 + - name: Edit PR Description + uses: velas/pr-description@v1.0.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + pull-request-number: ${{ steps.readctx.outputs.prnumber }} + description-message: | + Preview: ${{ steps.netlify.outputs.deploy-url }} + ⚠️ Do you trust the author of this PR? Maybe this build will steal your keys or give you malware. Exercise caution. Use test accounts. + diff --git a/.github/workflows/preview_changelog.yaml b/.github/workflows/preview_changelog.yaml new file mode 100644 index 0000000000..d68d19361d --- /dev/null +++ b/.github/workflows/preview_changelog.yaml @@ -0,0 +1,12 @@ +name: Preview Changelog +on: + pull_request_target: + types: [ opened, edited, labeled ] +jobs: + changelog: + runs-on: ubuntu-latest + steps: + - name: Preview Changelog + uses: matrix-org/allchange@main + with: + ghToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/typecheck.yaml b/.github/workflows/typecheck.yaml new file mode 100644 index 0000000000..f6ab643958 --- /dev/null +++ b/.github/workflows/typecheck.yaml @@ -0,0 +1,26 @@ +name: Type Check +on: + pull_request: + branches: [develop] +jobs: + build: + runs-on: ubuntu-latest + env: + PR_NUMBER: ${{github.event.number}} + steps: + - uses: actions/checkout@v2 + - uses: c-hive/gha-yarn-cache@v2 + - name: Install Deps + run: "./scripts/ci/install-deps.sh --ignore-scripts" + - name: Typecheck + run: "yarn run lint:types" + - name: Switch js-sdk to release mode + run: | + scripts/ci/js-sdk-to-release.js + cd node_modules/matrix-js-sdk + yarn install + yarn run build:compile + yarn run build:types + - name: Typecheck (release mode) + run: "yarn run lint:types" + diff --git a/.gitignore b/.gitignore index 50aa10fbfd..102f4b5ec1 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ package-lock.json .DS_Store *.tmp + +.vscode +.vscode/ diff --git a/.node-version b/.node-version new file mode 100644 index 0000000000..8351c19397 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +14 diff --git a/.stylelintrc.js b/.stylelintrc.js index 0e6de7000f..c044b19a63 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -17,6 +17,7 @@ module.exports = { "selector-list-comma-newline-after": null, "at-rule-no-unknown": null, "no-descending-specificity": null, + "no-empty-first-line": true, "scss/at-rule-no-unknown": [true, { // https://github.com/vector-im/element-web/issues/10544 "ignoreAtRules": ["define-mixin"], diff --git a/CHANGELOG.md b/CHANGELOG.md index f3d9afd51d..c28d72a3eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,964 @@ +Changes in [3.31.0](https://github.com/vector-im/element-desktop/releases/tag/v3.31.0) (2021-09-27) +=================================================================================================== + +## ✨ Features + * Say Joining space instead of Joining room where we know its a space ([\#6818](https://github.com/matrix-org/matrix-react-sdk/pull/6818)). Fixes vector-im/element-web#19064 and vector-im/element-web#19064. + * Add warning that some spaces may not be relinked to the newly upgraded room ([\#6805](https://github.com/matrix-org/matrix-react-sdk/pull/6805)). Fixes vector-im/element-web#18858 and vector-im/element-web#18858. + * Delabs Spaces, iterate some copy and move communities/space toggle to preferences ([\#6594](https://github.com/matrix-org/matrix-react-sdk/pull/6594)). Fixes vector-im/element-web#18088, vector-im/element-web#18524 vector-im/element-web#18088 and vector-im/element-web#18088. + * Show "Message" in the user info panel instead of "Start chat" ([\#6319](https://github.com/matrix-org/matrix-react-sdk/pull/6319)). Fixes vector-im/element-web#17877 and vector-im/element-web#17877. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix space keyboard shortcuts conflicting with native zoom shortcuts ([\#6804](https://github.com/matrix-org/matrix-react-sdk/pull/6804)). + * Replace plain text emoji at the end of a line ([\#6784](https://github.com/matrix-org/matrix-react-sdk/pull/6784)). Fixes vector-im/element-web#18833 and vector-im/element-web#18833. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Simplify Space Panel layout and fix some edge cases ([\#6800](https://github.com/matrix-org/matrix-react-sdk/pull/6800)). Fixes vector-im/element-web#18694 and vector-im/element-web#18694. + * Show unsent message warning on Space Panel buttons ([\#6778](https://github.com/matrix-org/matrix-react-sdk/pull/6778)). Fixes vector-im/element-web#18891 and vector-im/element-web#18891. + * Hide mute/unmute button in UserInfo for Spaces as it makes no sense ([\#6790](https://github.com/matrix-org/matrix-react-sdk/pull/6790)). Fixes vector-im/element-web#19007 and vector-im/element-web#19007. + * Fix automatic field population in space create menu not validating ([\#6792](https://github.com/matrix-org/matrix-react-sdk/pull/6792)). Fixes vector-im/element-web#19005 and vector-im/element-web#19005. + * Optimize input label transition on focus ([\#6783](https://github.com/matrix-org/matrix-react-sdk/pull/6783)). Fixes vector-im/element-web#12876 and vector-im/element-web#12876. Contributed by [MadLittleMods](https://github.com/MadLittleMods). + * Adapt and re-use the RolesRoomSettingsTab for Spaces ([\#6779](https://github.com/matrix-org/matrix-react-sdk/pull/6779)). Fixes vector-im/element-web#18908 vector-im/element-web#18909 and vector-im/element-web#18908. + * Deduplicate join rule management between rooms and spaces ([\#6724](https://github.com/matrix-org/matrix-react-sdk/pull/6724)). Fixes vector-im/element-web#18798 and vector-im/element-web#18798. + * Add config option to turn on in-room event sending timing metrics ([\#6766](https://github.com/matrix-org/matrix-react-sdk/pull/6766)). + * Improve the upgrade for restricted user experience ([\#6764](https://github.com/matrix-org/matrix-react-sdk/pull/6764)). Fixes vector-im/element-web#18677 and vector-im/element-web#18677. + * Improve tooltips on space quick actions and explore button ([\#6760](https://github.com/matrix-org/matrix-react-sdk/pull/6760)). Fixes vector-im/element-web#18528 and vector-im/element-web#18528. + * Make space members and user info behave more expectedly ([\#6765](https://github.com/matrix-org/matrix-react-sdk/pull/6765)). Fixes vector-im/element-web#17018 and vector-im/element-web#17018. + * hide no-op m.room.encryption events and better word param changes ([\#6747](https://github.com/matrix-org/matrix-react-sdk/pull/6747)). Fixes vector-im/element-web#18597 and vector-im/element-web#18597. + * Respect m.space.parent relations if they hold valid permissions ([\#6746](https://github.com/matrix-org/matrix-react-sdk/pull/6746)). Fixes vector-im/element-web#10935 and vector-im/element-web#10935. + * Space panel accessibility improvements ([\#6744](https://github.com/matrix-org/matrix-react-sdk/pull/6744)). Fixes vector-im/element-web#18892 and vector-im/element-web#18892. + +## 🐛 Bug Fixes + * Fix spacing for message composer buttons ([\#6854](https://github.com/matrix-org/matrix-react-sdk/pull/6854)). + * Fix accessing field on oobData which may be undefined ([\#6830](https://github.com/matrix-org/matrix-react-sdk/pull/6830)). Fixes vector-im/element-web#19085 and vector-im/element-web#19085. + * Fix reactions aria-label not being a string and thus being read as [Object object] ([\#6828](https://github.com/matrix-org/matrix-react-sdk/pull/6828)). + * Fix missing null guard in space hierarchy pagination ([\#6821](https://github.com/matrix-org/matrix-react-sdk/pull/6821)). Fixes matrix-org/element-web-rageshakes#6299 and matrix-org/element-web-rageshakes#6299. + * Fix checks to show prompt to start new chats ([\#6812](https://github.com/matrix-org/matrix-react-sdk/pull/6812)). + * Fix room list scroll jumps ([\#6777](https://github.com/matrix-org/matrix-react-sdk/pull/6777)). Fixes vector-im/element-web#17460 vector-im/element-web#18440 and vector-im/element-web#17460. Contributed by [robintown](https://github.com/robintown). + * Fix various message bubble alignment issues ([\#6785](https://github.com/matrix-org/matrix-react-sdk/pull/6785)). Fixes vector-im/element-web#18293, vector-im/element-web#18294 vector-im/element-web#18305 and vector-im/element-web#18293. Contributed by [robintown](https://github.com/robintown). + * Make message bubble font size consistent ([\#6795](https://github.com/matrix-org/matrix-react-sdk/pull/6795)). Contributed by [robintown](https://github.com/robintown). + * Fix edge cases around joining new room which does not belong to active space ([\#6797](https://github.com/matrix-org/matrix-react-sdk/pull/6797)). Fixes vector-im/element-web#19025 and vector-im/element-web#19025. + * Fix edge case space issues around creation and initial view ([\#6798](https://github.com/matrix-org/matrix-react-sdk/pull/6798)). Fixes vector-im/element-web#19023 and vector-im/element-web#19023. + * Stop spinner on space preview if the join fails ([\#6803](https://github.com/matrix-org/matrix-react-sdk/pull/6803)). Fixes vector-im/element-web#19034 and vector-im/element-web#19034. + * Fix emoji picker and stickerpicker not appearing correctly when opened ([\#6793](https://github.com/matrix-org/matrix-react-sdk/pull/6793)). Fixes vector-im/element-web#19012 and vector-im/element-web#19012. Contributed by [Palid](https://github.com/Palid). + * Fix autocomplete not having y-scroll ([\#6794](https://github.com/matrix-org/matrix-react-sdk/pull/6794)). Fixes vector-im/element-web#18997 and vector-im/element-web#18997. Contributed by [Palid](https://github.com/Palid). + * Fix broken edge case with public space creation with no alias ([\#6791](https://github.com/matrix-org/matrix-react-sdk/pull/6791)). Fixes vector-im/element-web#19003 and vector-im/element-web#19003. + * Redirect from /#/welcome to /#/home if already logged in ([\#6786](https://github.com/matrix-org/matrix-react-sdk/pull/6786)). Fixes vector-im/element-web#18990 and vector-im/element-web#18990. Contributed by [aaronraimist](https://github.com/aaronraimist). + * Fix build issues from two conflicting PRs landing without merge conflict ([\#6780](https://github.com/matrix-org/matrix-react-sdk/pull/6780)). + * Render guest settings only in public rooms/spaces ([\#6693](https://github.com/matrix-org/matrix-react-sdk/pull/6693)). Fixes vector-im/element-web#18776 and vector-im/element-web#18776. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix message bubble corners being wrong in the presence of hidden events ([\#6776](https://github.com/matrix-org/matrix-react-sdk/pull/6776)). Fixes vector-im/element-web#18124 and vector-im/element-web#18124. Contributed by [robintown](https://github.com/robintown). + * Debounce read marker update on scroll ([\#6771](https://github.com/matrix-org/matrix-react-sdk/pull/6771)). Fixes vector-im/element-web#18961 and vector-im/element-web#18961. + * Use cursor:pointer on space panel buttons ([\#6770](https://github.com/matrix-org/matrix-react-sdk/pull/6770)). Fixes vector-im/element-web#18951 and vector-im/element-web#18951. + * Fix regressed tab view buttons in space update toast ([\#6761](https://github.com/matrix-org/matrix-react-sdk/pull/6761)). Fixes vector-im/element-web#18781 and vector-im/element-web#18781. + +Changes in [3.31.0-rc.2](https://github.com/vector-im/element-desktop/releases/tag/v3.31.0-rc.2) (2021-09-22) +============================================================================================================= + +## 🐛 Bug Fixes + * Fix spacing for message composer buttons ([\#6854](https://github.com/matrix-org/matrix-react-sdk/pull/6854)). + +Changes in [3.31.0-rc.1](https://github.com/vector-im/element-desktop/releases/tag/v3.31.0-rc.1) (2021-09-21) +============================================================================================================= + +## ✨ Features + * Say Joining space instead of Joining room where we know its a space ([\#6818](https://github.com/matrix-org/matrix-react-sdk/pull/6818)). Fixes vector-im/element-web#19064 and vector-im/element-web#19064. + * Add warning that some spaces may not be relinked to the newly upgraded room ([\#6805](https://github.com/matrix-org/matrix-react-sdk/pull/6805)). Fixes vector-im/element-web#18858 and vector-im/element-web#18858. + * Delabs Spaces, iterate some copy and move communities/space toggle to preferences ([\#6594](https://github.com/matrix-org/matrix-react-sdk/pull/6594)). Fixes vector-im/element-web#18088, vector-im/element-web#18524 vector-im/element-web#18088 and vector-im/element-web#18088. + * Show "Message" in the user info panel instead of "Start chat" ([\#6319](https://github.com/matrix-org/matrix-react-sdk/pull/6319)). Fixes vector-im/element-web#17877 and vector-im/element-web#17877. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix space keyboard shortcuts conflicting with native zoom shortcuts ([\#6804](https://github.com/matrix-org/matrix-react-sdk/pull/6804)). + * Replace plain text emoji at the end of a line ([\#6784](https://github.com/matrix-org/matrix-react-sdk/pull/6784)). Fixes vector-im/element-web#18833 and vector-im/element-web#18833. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Simplify Space Panel layout and fix some edge cases ([\#6800](https://github.com/matrix-org/matrix-react-sdk/pull/6800)). Fixes vector-im/element-web#18694 and vector-im/element-web#18694. + * Show unsent message warning on Space Panel buttons ([\#6778](https://github.com/matrix-org/matrix-react-sdk/pull/6778)). Fixes vector-im/element-web#18891 and vector-im/element-web#18891. + * Hide mute/unmute button in UserInfo for Spaces as it makes no sense ([\#6790](https://github.com/matrix-org/matrix-react-sdk/pull/6790)). Fixes vector-im/element-web#19007 and vector-im/element-web#19007. + * Fix automatic field population in space create menu not validating ([\#6792](https://github.com/matrix-org/matrix-react-sdk/pull/6792)). Fixes vector-im/element-web#19005 and vector-im/element-web#19005. + * Optimize input label transition on focus ([\#6783](https://github.com/matrix-org/matrix-react-sdk/pull/6783)). Fixes vector-im/element-web#12876 and vector-im/element-web#12876. Contributed by [MadLittleMods](https://github.com/MadLittleMods). + * Adapt and re-use the RolesRoomSettingsTab for Spaces ([\#6779](https://github.com/matrix-org/matrix-react-sdk/pull/6779)). Fixes vector-im/element-web#18908 vector-im/element-web#18909 and vector-im/element-web#18908. + * Deduplicate join rule management between rooms and spaces ([\#6724](https://github.com/matrix-org/matrix-react-sdk/pull/6724)). Fixes vector-im/element-web#18798 and vector-im/element-web#18798. + * Add config option to turn on in-room event sending timing metrics ([\#6766](https://github.com/matrix-org/matrix-react-sdk/pull/6766)). + * Improve the upgrade for restricted user experience ([\#6764](https://github.com/matrix-org/matrix-react-sdk/pull/6764)). Fixes vector-im/element-web#18677 and vector-im/element-web#18677. + * Improve tooltips on space quick actions and explore button ([\#6760](https://github.com/matrix-org/matrix-react-sdk/pull/6760)). Fixes vector-im/element-web#18528 and vector-im/element-web#18528. + * Make space members and user info behave more expectedly ([\#6765](https://github.com/matrix-org/matrix-react-sdk/pull/6765)). Fixes vector-im/element-web#17018 and vector-im/element-web#17018. + * hide no-op m.room.encryption events and better word param changes ([\#6747](https://github.com/matrix-org/matrix-react-sdk/pull/6747)). Fixes vector-im/element-web#18597 and vector-im/element-web#18597. + * Respect m.space.parent relations if they hold valid permissions ([\#6746](https://github.com/matrix-org/matrix-react-sdk/pull/6746)). Fixes vector-im/element-web#10935 and vector-im/element-web#10935. + * Space panel accessibility improvements ([\#6744](https://github.com/matrix-org/matrix-react-sdk/pull/6744)). Fixes vector-im/element-web#18892 and vector-im/element-web#18892. + +## 🐛 Bug Fixes + * Revert Firefox composer deletion hacks ([\#6844](https://github.com/matrix-org/matrix-react-sdk/pull/6844)). Fixes vector-im/element-web#19103 and vector-im/element-web#19103. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix accessing field on oobData which may be undefined ([\#6830](https://github.com/matrix-org/matrix-react-sdk/pull/6830)). Fixes vector-im/element-web#19085 and vector-im/element-web#19085. + * Fix pill deletion on Firefox 78 ([\#6832](https://github.com/matrix-org/matrix-react-sdk/pull/6832)). Fixes vector-im/element-web#19077 and vector-im/element-web#19077. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix reactions aria-label not being a string and thus being read as [Object object] ([\#6828](https://github.com/matrix-org/matrix-react-sdk/pull/6828)). + * Fix missing null guard in space hierarchy pagination ([\#6821](https://github.com/matrix-org/matrix-react-sdk/pull/6821)). Fixes matrix-org/element-web-rageshakes#6299 and matrix-org/element-web-rageshakes#6299. + * Fix checks to show prompt to start new chats ([\#6812](https://github.com/matrix-org/matrix-react-sdk/pull/6812)). + * Fix room list scroll jumps ([\#6777](https://github.com/matrix-org/matrix-react-sdk/pull/6777)). Fixes vector-im/element-web#17460 vector-im/element-web#18440 and vector-im/element-web#17460. Contributed by [robintown](https://github.com/robintown). + * Fix various message bubble alignment issues ([\#6785](https://github.com/matrix-org/matrix-react-sdk/pull/6785)). Fixes vector-im/element-web#18293, vector-im/element-web#18294 vector-im/element-web#18305 and vector-im/element-web#18293. Contributed by [robintown](https://github.com/robintown). + * Make message bubble font size consistent ([\#6795](https://github.com/matrix-org/matrix-react-sdk/pull/6795)). Contributed by [robintown](https://github.com/robintown). + * Fix edge cases around joining new room which does not belong to active space ([\#6797](https://github.com/matrix-org/matrix-react-sdk/pull/6797)). Fixes vector-im/element-web#19025 and vector-im/element-web#19025. + * Fix edge case space issues around creation and initial view ([\#6798](https://github.com/matrix-org/matrix-react-sdk/pull/6798)). Fixes vector-im/element-web#19023 and vector-im/element-web#19023. + * Stop spinner on space preview if the join fails ([\#6803](https://github.com/matrix-org/matrix-react-sdk/pull/6803)). Fixes vector-im/element-web#19034 and vector-im/element-web#19034. + * Fix emoji picker and stickerpicker not appearing correctly when opened ([\#6793](https://github.com/matrix-org/matrix-react-sdk/pull/6793)). Fixes vector-im/element-web#19012 and vector-im/element-web#19012. Contributed by [Palid](https://github.com/Palid). + * Fix autocomplete not having y-scroll ([\#6794](https://github.com/matrix-org/matrix-react-sdk/pull/6794)). Fixes vector-im/element-web#18997 and vector-im/element-web#18997. Contributed by [Palid](https://github.com/Palid). + * Fix broken edge case with public space creation with no alias ([\#6791](https://github.com/matrix-org/matrix-react-sdk/pull/6791)). Fixes vector-im/element-web#19003 and vector-im/element-web#19003. + * Redirect from /#/welcome to /#/home if already logged in ([\#6786](https://github.com/matrix-org/matrix-react-sdk/pull/6786)). Fixes vector-im/element-web#18990 and vector-im/element-web#18990. Contributed by [aaronraimist](https://github.com/aaronraimist). + * Fix build issues from two conflicting PRs landing without merge conflict ([\#6780](https://github.com/matrix-org/matrix-react-sdk/pull/6780)). + * Render guest settings only in public rooms/spaces ([\#6693](https://github.com/matrix-org/matrix-react-sdk/pull/6693)). Fixes vector-im/element-web#18776 and vector-im/element-web#18776. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix message bubble corners being wrong in the presence of hidden events ([\#6776](https://github.com/matrix-org/matrix-react-sdk/pull/6776)). Fixes vector-im/element-web#18124 and vector-im/element-web#18124. Contributed by [robintown](https://github.com/robintown). + * Debounce read marker update on scroll ([\#6771](https://github.com/matrix-org/matrix-react-sdk/pull/6771)). Fixes vector-im/element-web#18961 and vector-im/element-web#18961. + * Use cursor:pointer on space panel buttons ([\#6770](https://github.com/matrix-org/matrix-react-sdk/pull/6770)). Fixes vector-im/element-web#18951 and vector-im/element-web#18951. + * Fix regressed tab view buttons in space update toast ([\#6761](https://github.com/matrix-org/matrix-react-sdk/pull/6761)). Fixes vector-im/element-web#18781 and vector-im/element-web#18781. + +Changes in [3.30.0](https://github.com/vector-im/element-desktop/releases/tag/v3.30.0) (2021-09-14) +=================================================================================================== + +## ✨ Features + * Add bubble highlight styling ([\#6582](https://github.com/matrix-org/matrix-react-sdk/pull/6582)). Fixes vector-im/element-web#18295 and vector-im/element-web#18295. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * [Release] Add config option to turn on in-room event sending timing metrics ([\#6773](https://github.com/matrix-org/matrix-react-sdk/pull/6773)). + * Create narrow mode for Composer ([\#6682](https://github.com/matrix-org/matrix-react-sdk/pull/6682)). Fixes vector-im/element-web#18533 and vector-im/element-web#18533. + * Prefer matrix.to alias links over room id in spaces & share ([\#6745](https://github.com/matrix-org/matrix-react-sdk/pull/6745)). Fixes vector-im/element-web#18796 and vector-im/element-web#18796. + * Stop automatic playback of voice messages if a non-voice message is encountered ([\#6728](https://github.com/matrix-org/matrix-react-sdk/pull/6728)). Fixes vector-im/element-web#18850 and vector-im/element-web#18850. + * Show call length during a call ([\#6700](https://github.com/matrix-org/matrix-react-sdk/pull/6700)). Fixes vector-im/element-web#18566 and vector-im/element-web#18566. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Serialize and retry mass-leave when leaving space ([\#6737](https://github.com/matrix-org/matrix-react-sdk/pull/6737)). Fixes vector-im/element-web#18789 and vector-im/element-web#18789. + * Improve form handling in and around space creation ([\#6739](https://github.com/matrix-org/matrix-react-sdk/pull/6739)). Fixes vector-im/element-web#18775 and vector-im/element-web#18775. + * Split autoplay GIFs and videos into different settings ([\#6726](https://github.com/matrix-org/matrix-react-sdk/pull/6726)). Fixes vector-im/element-web#5771 and vector-im/element-web#5771. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Add autoplay for voice messages ([\#6710](https://github.com/matrix-org/matrix-react-sdk/pull/6710)). Fixes vector-im/element-web#18804, vector-im/element-web#18715, vector-im/element-web#18714 vector-im/element-web#17961 and vector-im/element-web#18804. + * Allow to use basic html to format invite messages ([\#6703](https://github.com/matrix-org/matrix-react-sdk/pull/6703)). Fixes vector-im/element-web#15738 and vector-im/element-web#15738. Contributed by [skolmer](https://github.com/skolmer). + * Allow widgets, when eligible, to interact with more rooms as per MSC2762 ([\#6684](https://github.com/matrix-org/matrix-react-sdk/pull/6684)). + * Remove arbitrary limits from send/receive events for widgets ([\#6719](https://github.com/matrix-org/matrix-react-sdk/pull/6719)). Fixes vector-im/element-web#17994 and vector-im/element-web#17994. + * Reload suggested rooms if we see the state change down /sync ([\#6715](https://github.com/matrix-org/matrix-react-sdk/pull/6715)). Fixes vector-im/element-web#18761 and vector-im/element-web#18761. + * When creating private spaces, make the initial rooms restricted if supported ([\#6721](https://github.com/matrix-org/matrix-react-sdk/pull/6721)). Fixes vector-im/element-web#18722 and vector-im/element-web#18722. + * Threading exploration work ([\#6658](https://github.com/matrix-org/matrix-react-sdk/pull/6658)). Fixes vector-im/element-web#18532 and vector-im/element-web#18532. + * Default to `Don't leave any` when leaving a space ([\#6697](https://github.com/matrix-org/matrix-react-sdk/pull/6697)). Fixes vector-im/element-web#18592 and vector-im/element-web#18592. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Special case redaction event sending from widgets per MSC2762 ([\#6686](https://github.com/matrix-org/matrix-react-sdk/pull/6686)). Fixes vector-im/element-web#18573 and vector-im/element-web#18573. + * Add active speaker indicators ([\#6639](https://github.com/matrix-org/matrix-react-sdk/pull/6639)). Fixes vector-im/element-web#17627 and vector-im/element-web#17627. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Increase general app performance by optimizing layers ([\#6644](https://github.com/matrix-org/matrix-react-sdk/pull/6644)). Fixes vector-im/element-web#18730 and vector-im/element-web#18730. Contributed by [Palid](https://github.com/Palid). + +## 🐛 Bug Fixes + * Fix autocomplete not having y-scroll ([\#6802](https://github.com/matrix-org/matrix-react-sdk/pull/6802)). + * Fix emoji picker and stickerpicker not appearing correctly when opened ([\#6801](https://github.com/matrix-org/matrix-react-sdk/pull/6801)). + * Debounce read marker update on scroll ([\#6774](https://github.com/matrix-org/matrix-react-sdk/pull/6774)). + * Fix Space creation wizard go to my first room button behaviour ([\#6748](https://github.com/matrix-org/matrix-react-sdk/pull/6748)). Fixes vector-im/element-web#18764 and vector-im/element-web#18764. + * Fix scroll being stuck at bottom ([\#6751](https://github.com/matrix-org/matrix-react-sdk/pull/6751)). Fixes vector-im/element-web#18903 and vector-im/element-web#18903. + * Fix widgets not remembering identity verification when asked to. ([\#6742](https://github.com/matrix-org/matrix-react-sdk/pull/6742)). Fixes vector-im/element-web#15631 and vector-im/element-web#15631. + * Add missing pluralisation i18n strings for Spaces ([\#6738](https://github.com/matrix-org/matrix-react-sdk/pull/6738)). Fixes vector-im/element-web#18780 and vector-im/element-web#18780. + * Make ForgotPassword UX slightly more user friendly ([\#6636](https://github.com/matrix-org/matrix-react-sdk/pull/6636)). Fixes vector-im/element-web#11531 and vector-im/element-web#11531. Contributed by [Palid](https://github.com/Palid). + * Don't context switch room on SpaceStore ready as it can break permalinks ([\#6730](https://github.com/matrix-org/matrix-react-sdk/pull/6730)). Fixes vector-im/element-web#17974 and vector-im/element-web#17974. + * Fix explore rooms button not working during space creation wizard ([\#6729](https://github.com/matrix-org/matrix-react-sdk/pull/6729)). Fixes vector-im/element-web#18762 and vector-im/element-web#18762. + * Fix bug where one party's media would sometimes not be shown ([\#6731](https://github.com/matrix-org/matrix-react-sdk/pull/6731)). + * Only make the initial space rooms suggested by default ([\#6714](https://github.com/matrix-org/matrix-react-sdk/pull/6714)). Fixes vector-im/element-web#18760 and vector-im/element-web#18760. + * Replace fake username in EventTilePreview with a proper loading state ([\#6702](https://github.com/matrix-org/matrix-react-sdk/pull/6702)). Fixes vector-im/element-web#15897 and vector-im/element-web#15897. Contributed by [skolmer](https://github.com/skolmer). + * Don't send prehistorical events to widgets during decryption at startup ([\#6695](https://github.com/matrix-org/matrix-react-sdk/pull/6695)). Fixes vector-im/element-web#18060 and vector-im/element-web#18060. + * When creating subspaces properly set restricted join rule ([\#6725](https://github.com/matrix-org/matrix-react-sdk/pull/6725)). Fixes vector-im/element-web#18797 and vector-im/element-web#18797. + * Fix the Image View not openning for some pinned messages ([\#6723](https://github.com/matrix-org/matrix-react-sdk/pull/6723)). Fixes vector-im/element-web#18422 and vector-im/element-web#18422. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Show autocomplete sections vertically ([\#6722](https://github.com/matrix-org/matrix-react-sdk/pull/6722)). Fixes vector-im/element-web#18860 and vector-im/element-web#18860. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix EmojiPicker filtering to lower case emojibase data strings ([\#6717](https://github.com/matrix-org/matrix-react-sdk/pull/6717)). Fixes vector-im/element-web#18686 and vector-im/element-web#18686. + * Clear currentRoomId when viewing home page, fixing document title ([\#6716](https://github.com/matrix-org/matrix-react-sdk/pull/6716)). Fixes vector-im/element-web#18668 and vector-im/element-web#18668. + * Fix membership updates to Spaces not applying in real-time ([\#6713](https://github.com/matrix-org/matrix-react-sdk/pull/6713)). Fixes vector-im/element-web#18737 and vector-im/element-web#18737. + * Don't show a double stacked invite modals when inviting to Spaces ([\#6698](https://github.com/matrix-org/matrix-react-sdk/pull/6698)). Fixes vector-im/element-web#18745 and vector-im/element-web#18745. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Remove non-functional DuckDuckGo Autocomplete Provider ([\#6712](https://github.com/matrix-org/matrix-react-sdk/pull/6712)). Fixes vector-im/element-web#18778 and vector-im/element-web#18778. + * Filter members on `MemberList` load ([\#6708](https://github.com/matrix-org/matrix-react-sdk/pull/6708)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix improper voice messages being produced in Firefox and sometimes other browsers. ([\#6696](https://github.com/matrix-org/matrix-react-sdk/pull/6696)). Fixes vector-im/element-web#18587 and vector-im/element-web#18587. + * Fix client forgetting which capabilities a widget was approved for ([\#6685](https://github.com/matrix-org/matrix-react-sdk/pull/6685)). Fixes vector-im/element-web#18786 and vector-im/element-web#18786. + * Fix left panel widgets not remembering collapsed state ([\#6687](https://github.com/matrix-org/matrix-react-sdk/pull/6687)). Fixes vector-im/element-web#17803 and vector-im/element-web#17803. + * Fix changelog link colour back to blue ([\#6692](https://github.com/matrix-org/matrix-react-sdk/pull/6692)). Fixes vector-im/element-web#18726 and vector-im/element-web#18726. + * Soften codeblock border color ([\#6564](https://github.com/matrix-org/matrix-react-sdk/pull/6564)). Fixes vector-im/element-web#18367 and vector-im/element-web#18367. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Pause ringing more aggressively ([\#6691](https://github.com/matrix-org/matrix-react-sdk/pull/6691)). Fixes vector-im/element-web#18588 and vector-im/element-web#18588. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix command autocomplete ([\#6680](https://github.com/matrix-org/matrix-react-sdk/pull/6680)). Fixes vector-im/element-web#18670 and vector-im/element-web#18670. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Don't re-sort the room-list based on profile/status changes ([\#6595](https://github.com/matrix-org/matrix-react-sdk/pull/6595)). Fixes vector-im/element-web#110 and vector-im/element-web#110. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix codeblock formatting with syntax highlighting on ([\#6681](https://github.com/matrix-org/matrix-react-sdk/pull/6681)). Fixes vector-im/element-web#18739 vector-im/element-web#18365 and vector-im/element-web#18739. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Add padding to the Add button in the notification settings ([\#6665](https://github.com/matrix-org/matrix-react-sdk/pull/6665)). Fixes vector-im/element-web#18706 and vector-im/element-web#18706. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + +Changes in [3.29.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.29.1) (2021-09-13) +=================================================================================================== + +## 🔒 SECURITY FIXES + * Fix a security issue with message key sharing. See https://matrix.org/blog/2021/09/13/vulnerability-disclosure-key-sharing + for details. + +Changes in [3.29.0](https://github.com/vector-im/element-desktop/releases/tag/v3.29.0) (2021-08-31) +=================================================================================================== + +## ✨ Features + * [Release]Increase general app performance by optimizing layers ([\#6672](https://github.com/matrix-org/matrix-react-sdk/pull/6672)). Fixes vector-im/element-web#18730 and vector-im/element-web#18730. Contributed by [Palid](https://github.com/Palid). + * Add a warning on E2EE rooms if you try to make them public ([\#5698](https://github.com/matrix-org/matrix-react-sdk/pull/5698)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Allow pagination of the space hierarchy and use new APIs ([\#6507](https://github.com/matrix-org/matrix-react-sdk/pull/6507)). Fixes vector-im/element-web#18089 and vector-im/element-web#18427. + * Improve emoji in composer ([\#6650](https://github.com/matrix-org/matrix-react-sdk/pull/6650)). Fixes vector-im/element-web#18593 and vector-im/element-web#18593. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Allow playback of replied-to voice message ([\#6629](https://github.com/matrix-org/matrix-react-sdk/pull/6629)). Fixes vector-im/element-web#18599 and vector-im/element-web#18599. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Format autocomplete suggestions vertically ([\#6620](https://github.com/matrix-org/matrix-react-sdk/pull/6620)). Fixes vector-im/element-web#17574 and vector-im/element-web#17574. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Remember last `MemberList` search query per-room ([\#6640](https://github.com/matrix-org/matrix-react-sdk/pull/6640)). Fixes vector-im/element-web#18613 and vector-im/element-web#18613. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Sentry rageshakes ([\#6597](https://github.com/matrix-org/matrix-react-sdk/pull/6597)). Fixes vector-im/element-web#11111 and vector-im/element-web#11111. Contributed by [novocaine](https://github.com/novocaine). + * Autocomplete has been updated to match modern accessibility standards. Navigate via up/down arrows rather than Tab. Enter or Tab to confirm a suggestion. This should be familiar to Slack & Discord users. You can now use Tab to navigate around the application and do more without touching your mouse. No more accidentally sending half of people's names because the completion didn't fire on Enter! ([\#5659](https://github.com/matrix-org/matrix-react-sdk/pull/5659)). Fixes vector-im/element-web#4872, vector-im/element-web#11071, vector-im/element-web#17171, vector-im/element-web#15646 vector-im/element-web#4872 and vector-im/element-web#4872. + * Add new call tile states ([\#6610](https://github.com/matrix-org/matrix-react-sdk/pull/6610)). Fixes vector-im/element-web#18521 and vector-im/element-web#18521. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Left align call tiles ([\#6609](https://github.com/matrix-org/matrix-react-sdk/pull/6609)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Make loading encrypted images look snappier ([\#6590](https://github.com/matrix-org/matrix-react-sdk/pull/6590)). Fixes vector-im/element-web#17878 and vector-im/element-web#17862. Contributed by [Palid](https://github.com/Palid). + * Offer a way to create a space based on existing community ([\#6543](https://github.com/matrix-org/matrix-react-sdk/pull/6543)). Fixes vector-im/element-web#18092. + * Accessibility improvements in and around Spaces ([\#6569](https://github.com/matrix-org/matrix-react-sdk/pull/6569)). Fixes vector-im/element-web#18094 and vector-im/element-web#18094. + +## 🐛 Bug Fixes + * [Release] Fix commit edit history ([\#6690](https://github.com/matrix-org/matrix-react-sdk/pull/6690)). Fixes vector-im/element-web#18742 and vector-im/element-web#18742. Contributed by [Palid](https://github.com/Palid). + * Fix images not rendering when sent from other clients. ([\#6661](https://github.com/matrix-org/matrix-react-sdk/pull/6661)). Fixes vector-im/element-web#18702 and vector-im/element-web#18702. + * Fix autocomplete scrollbar and make the autocomplete a little smaller ([\#6655](https://github.com/matrix-org/matrix-react-sdk/pull/6655)). Fixes vector-im/element-web#18682 and vector-im/element-web#18682. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix replies on the bubble layout ([\#6451](https://github.com/matrix-org/matrix-react-sdk/pull/6451)). Fixes vector-im/element-web#18184. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Show "Enable encryption in settings" only when the user can do that ([\#6646](https://github.com/matrix-org/matrix-react-sdk/pull/6646)). Fixes vector-im/element-web#18646 and vector-im/element-web#18646. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix cross signing setup from settings screen ([\#6633](https://github.com/matrix-org/matrix-react-sdk/pull/6633)). Fixes vector-im/element-web#17761 and vector-im/element-web#17761. + * Fix call tiles on the bubble layout ([\#6647](https://github.com/matrix-org/matrix-react-sdk/pull/6647)). Fixes vector-im/element-web#18648 and vector-im/element-web#18648. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix error on accessing encrypted media without encryption keys ([\#6625](https://github.com/matrix-org/matrix-react-sdk/pull/6625)). Contributed by [Palid](https://github.com/Palid). + * Fix jitsi widget sometimes being permanently stuck in the bottom-right corner ([\#6632](https://github.com/matrix-org/matrix-react-sdk/pull/6632)). Fixes vector-im/element-web#17226 and vector-im/element-web#17226. Contributed by [Palid](https://github.com/Palid). + * Fix FilePanel pagination in E2EE rooms ([\#6630](https://github.com/matrix-org/matrix-react-sdk/pull/6630)). Fixes vector-im/element-web#18415 and vector-im/element-web#18415. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix call tile buttons ([\#6624](https://github.com/matrix-org/matrix-react-sdk/pull/6624)). Fixes vector-im/element-web#18565 and vector-im/element-web#18565. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix vertical call tile spacing issues ([\#6621](https://github.com/matrix-org/matrix-react-sdk/pull/6621)). Fixes vector-im/element-web#18558 and vector-im/element-web#18558. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix long display names in call tiles ([\#6618](https://github.com/matrix-org/matrix-react-sdk/pull/6618)). Fixes vector-im/element-web#18562 and vector-im/element-web#18562. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Avoid access token overflow ([\#6616](https://github.com/matrix-org/matrix-react-sdk/pull/6616)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Properly handle media errors ([\#6615](https://github.com/matrix-org/matrix-react-sdk/pull/6615)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix glare related regressions ([\#6614](https://github.com/matrix-org/matrix-react-sdk/pull/6614)). Fixes vector-im/element-web#18538 and vector-im/element-web#18538. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix long display names in call toasts ([\#6617](https://github.com/matrix-org/matrix-react-sdk/pull/6617)). Fixes vector-im/element-web#18557 and vector-im/element-web#18557. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix PiP of held calls ([\#6611](https://github.com/matrix-org/matrix-react-sdk/pull/6611)). Fixes vector-im/element-web#18539 and vector-im/element-web#18539. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix call tile behaviour on narrow layouts ([\#6556](https://github.com/matrix-org/matrix-react-sdk/pull/6556)). Fixes vector-im/element-web#18398. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix video call persisting when widget removed ([\#6608](https://github.com/matrix-org/matrix-react-sdk/pull/6608)). Fixes vector-im/element-web#15703 and vector-im/element-web#15703. + * Fix toast colors ([\#6606](https://github.com/matrix-org/matrix-react-sdk/pull/6606)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Remove tiny scrollbar dot from code blocks ([\#6596](https://github.com/matrix-org/matrix-react-sdk/pull/6596)). Fixes vector-im/element-web#18474. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Improve handling of pills in the composer ([\#6353](https://github.com/matrix-org/matrix-react-sdk/pull/6353)). Fixes vector-im/element-web#10134 vector-im/element-web#10896 and vector-im/element-web#15037. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + +Changes in [3.28.1](https://github.com/vector-im/element-desktop/releases/tag/v3.28.1) (2021-08-17) +=================================================================================================== + +## 🐛 Bug Fixes + * Fix multiple VoIP regressions ([matrix-org/matrix-js-sdk#1860](https://github.com/matrix-org/matrix-js-sdk/pull/1860)). + +Changes in [3.28.0](https://github.com/vector-im/element-desktop/releases/tag/v3.28.0) (2021-08-16) +=================================================================================================== + +## ✨ Features + * Show how long a call was on call tiles ([\#6570](https://github.com/matrix-org/matrix-react-sdk/pull/6570)). Fixes vector-im/element-web#18405. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Add regional indicators to emoji picker ([\#6490](https://github.com/matrix-org/matrix-react-sdk/pull/6490)). Fixes vector-im/element-web#14963. Contributed by [robintown](https://github.com/robintown). + * Make call control buttons accessible to screen reader users ([\#6181](https://github.com/matrix-org/matrix-react-sdk/pull/6181)). Fixes vector-im/element-web#18358. Contributed by [pvagner](https://github.com/pvagner). + * Skip sending a thumbnail if it is not a sufficient saving over the original ([\#6559](https://github.com/matrix-org/matrix-react-sdk/pull/6559)). Fixes vector-im/element-web#17906. + * Increase PiP snapping speed ([\#6539](https://github.com/matrix-org/matrix-react-sdk/pull/6539)). Fixes vector-im/element-web#18371. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Improve and move the incoming call toast ([\#6470](https://github.com/matrix-org/matrix-react-sdk/pull/6470)). Fixes vector-im/element-web#17912. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Allow all of the URL schemes that Firefox allows ([\#6457](https://github.com/matrix-org/matrix-react-sdk/pull/6457)). Contributed by [aaronraimist](https://github.com/aaronraimist). + * Improve bubble layout colors ([\#6452](https://github.com/matrix-org/matrix-react-sdk/pull/6452)). Fixes vector-im/element-web#18081. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Spaces let users switch between Home and All Rooms behaviours ([\#6497](https://github.com/matrix-org/matrix-react-sdk/pull/6497)). Fixes vector-im/element-web#18093. + * Support for MSC2285 (hidden read receipts) ([\#6390](https://github.com/matrix-org/matrix-react-sdk/pull/6390)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Group pinned message events with MELS ([\#6349](https://github.com/matrix-org/matrix-react-sdk/pull/6349)). Fixes vector-im/element-web#17938. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Make version copiable ([\#6227](https://github.com/matrix-org/matrix-react-sdk/pull/6227)). Fixes vector-im/element-web#17603 and vector-im/element-web#18329. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Improve voice messages uploading state ([\#6530](https://github.com/matrix-org/matrix-react-sdk/pull/6530)). Fixes vector-im/element-web#18226 and vector-im/element-web#18224. + * Add surround with feature ([\#5510](https://github.com/matrix-org/matrix-react-sdk/pull/5510)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Improve call event tile wording ([\#6545](https://github.com/matrix-org/matrix-react-sdk/pull/6545)). Fixes vector-im/element-web#18376. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Show an avatar/a turned off microphone icon for muted users ([\#6486](https://github.com/matrix-org/matrix-react-sdk/pull/6486)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Prompt user to leave rooms/subspaces in a space when leaving space ([\#6424](https://github.com/matrix-org/matrix-react-sdk/pull/6424)). Fixes vector-im/element-web#18071. + * Add customisation point to override widget variables ([\#6455](https://github.com/matrix-org/matrix-react-sdk/pull/6455)). Fixes vector-im/element-web#18035. + * Add support for screen sharing in 1:1 calls ([\#5992](https://github.com/matrix-org/matrix-react-sdk/pull/5992)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + +## 🐛 Bug Fixes + * [Release] Fix glare related regressions ([\#6622](https://github.com/matrix-org/matrix-react-sdk/pull/6622)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * [Release] Fix PiP of held calls ([\#6612](https://github.com/matrix-org/matrix-react-sdk/pull/6612)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * [Release] Fix toast colors ([\#6607](https://github.com/matrix-org/matrix-react-sdk/pull/6607)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix [object Object] in Widget Permissions ([\#6560](https://github.com/matrix-org/matrix-react-sdk/pull/6560)). Fixes vector-im/element-web#18384. Contributed by [Palid](https://github.com/Palid). + * Fix right margin for events on IRC layout ([\#6542](https://github.com/matrix-org/matrix-react-sdk/pull/6542)). Fixes vector-im/element-web#18354. + * Mirror only usermedia feeds ([\#6512](https://github.com/matrix-org/matrix-react-sdk/pull/6512)). Fixes vector-im/element-web#5633. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix LogoutDialog warning + TypeScript migration ([\#6533](https://github.com/matrix-org/matrix-react-sdk/pull/6533)). + * Fix the wrong font being used in the room topic field ([\#6527](https://github.com/matrix-org/matrix-react-sdk/pull/6527)). Fixes vector-im/element-web#18339. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix inconsistent styling for links on hover ([\#6513](https://github.com/matrix-org/matrix-react-sdk/pull/6513)). Contributed by [janogarcia](https://github.com/janogarcia). + * Fix incorrect height for encoded placeholder images ([\#6514](https://github.com/matrix-org/matrix-react-sdk/pull/6514)). Contributed by [Palid](https://github.com/Palid). + * Fix call events layout for message bubble ([\#6465](https://github.com/matrix-org/matrix-react-sdk/pull/6465)). Fixes vector-im/element-web#18144. + * Improve subspaces and some utilities around room/space creation ([\#6458](https://github.com/matrix-org/matrix-react-sdk/pull/6458)). Fixes vector-im/element-web#18090 vector-im/element-web#18091 and vector-im/element-web#17256. + * Restore pointer cursor for SenderProfile in message bubbles ([\#6501](https://github.com/matrix-org/matrix-react-sdk/pull/6501)). Fixes vector-im/element-web#18249. + * Fix issues with the Call View ([\#6472](https://github.com/matrix-org/matrix-react-sdk/pull/6472)). Fixes vector-im/element-web#18221. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Align event list summary read receipts when using message bubbles ([\#6500](https://github.com/matrix-org/matrix-react-sdk/pull/6500)). Fixes vector-im/element-web#18143. + * Better positioning for unbubbled events in timeline ([\#6477](https://github.com/matrix-org/matrix-react-sdk/pull/6477)). Fixes vector-im/element-web#18132. + * Realign reactions row with messages in modern layout ([\#6491](https://github.com/matrix-org/matrix-react-sdk/pull/6491)). Fixes vector-im/element-web#18118. Contributed by [robintown](https://github.com/robintown). + * Fix CreateRoomDialog exploding when making public room outside of a space ([\#6492](https://github.com/matrix-org/matrix-react-sdk/pull/6492)). Fixes vector-im/element-web#18275. + * Fix call crashing because `element` was undefined ([\#6488](https://github.com/matrix-org/matrix-react-sdk/pull/6488)). Fixes vector-im/element-web#18270. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Upscale thumbnails to the container size ([\#6589](https://github.com/matrix-org/matrix-react-sdk/pull/6589)). Fixes vector-im/element-web#18307. + * Fix create room dialog in spaces no longer adding to the space ([\#6587](https://github.com/matrix-org/matrix-react-sdk/pull/6587)). Fixes vector-im/element-web#18465. + * Don't show a modal on call reject/user hangup ([\#6580](https://github.com/matrix-org/matrix-react-sdk/pull/6580)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fade Call View Buttons after `componentDidMount` ([\#6581](https://github.com/matrix-org/matrix-react-sdk/pull/6581)). Fixes vector-im/element-web#18439. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix missing expand button on codeblocks ([\#6565](https://github.com/matrix-org/matrix-react-sdk/pull/6565)). Fixes vector-im/element-web#18388. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * allow customizing the bubble layout colors ([\#6568](https://github.com/matrix-org/matrix-react-sdk/pull/6568)). Fixes vector-im/element-web#18408. Contributed by [benneti](https://github.com/benneti). + * Don't flash "Missed call" when accepting a call ([\#6567](https://github.com/matrix-org/matrix-react-sdk/pull/6567)). Fixes vector-im/element-web#18404. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix clicking whitespaces on replies ([\#6571](https://github.com/matrix-org/matrix-react-sdk/pull/6571)). Fixes vector-im/element-web#18327. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix disabled state for voice messages + send button tooltip ([\#6562](https://github.com/matrix-org/matrix-react-sdk/pull/6562)). Fixes vector-im/element-web#18413. + * Fix voice feed being cut-off ([\#6550](https://github.com/matrix-org/matrix-react-sdk/pull/6550)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix sizing issues of the screen picker ([\#6498](https://github.com/matrix-org/matrix-react-sdk/pull/6498)). Fixes vector-im/element-web#18281. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Stop voice messages that are playing when starting a recording ([\#6563](https://github.com/matrix-org/matrix-react-sdk/pull/6563)). Fixes vector-im/element-web#18410. + * Properly set style attribute on shared usercontent iframe ([\#6561](https://github.com/matrix-org/matrix-react-sdk/pull/6561)). Fixes vector-im/element-web#18414. + * Null guard space inviter to prevent the app exploding ([\#6558](https://github.com/matrix-org/matrix-react-sdk/pull/6558)). + * Make the ringing sound mutable/disablable ([\#6534](https://github.com/matrix-org/matrix-react-sdk/pull/6534)). Fixes vector-im/element-web#15591. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix wrong cursor being used in PiP ([\#6551](https://github.com/matrix-org/matrix-react-sdk/pull/6551)). Fixes vector-im/element-web#18383. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Re-pin Jitsi if the widget already exists ([\#6226](https://github.com/matrix-org/matrix-react-sdk/pull/6226)). Fixes vector-im/element-web#17679. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix broken call notification regression ([\#6526](https://github.com/matrix-org/matrix-react-sdk/pull/6526)). Fixes vector-im/element-web#18335. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * createRoom, only send join rule event if we have a join rule to put in it ([\#6516](https://github.com/matrix-org/matrix-react-sdk/pull/6516)). Fixes vector-im/element-web#18301. + * Fix clicking pills inside replies ([\#6508](https://github.com/matrix-org/matrix-react-sdk/pull/6508)). Fixes vector-im/element-web#18283. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix grecaptcha regression ([\#6503](https://github.com/matrix-org/matrix-react-sdk/pull/6503)). Fixes vector-im/element-web#18284. Contributed by [Palid](https://github.com/Palid). + +Changes in [3.27.0](https://github.com/vector-im/element-desktop/releases/tag/v3.27.0) (2021-08-02) +=================================================================================================== + +## 🔒 SECURITY FIXES + * Sanitize untrusted variables from message previews before translation + Fixes vector-im/element-web#18314 + +## ✨ Features + * Fix editing of `` & ` & `` + [\#6469](https://github.com/matrix-org/matrix-react-sdk/pull/6469) + Fixes vector-im/element-web#18211 + * Zoom images in lightbox to where the cursor points + [\#6418](https://github.com/matrix-org/matrix-react-sdk/pull/6418) + Fixes vector-im/element-web#17870 + * Avoid hitting the settings store from TextForEvent + [\#6205](https://github.com/matrix-org/matrix-react-sdk/pull/6205) + Fixes vector-im/element-web#17650 + * Initial MSC3083 + MSC3244 support + [\#6212](https://github.com/matrix-org/matrix-react-sdk/pull/6212) + Fixes vector-im/element-web#17686 and vector-im/element-web#17661 + * Navigate to the first room with notifications when clicked on space notification dot + [\#5974](https://github.com/matrix-org/matrix-react-sdk/pull/5974) + * Add matrix: to the list of permitted URL schemes + [\#6388](https://github.com/matrix-org/matrix-react-sdk/pull/6388) + * Add "Copy Link" to room context menu + [\#6374](https://github.com/matrix-org/matrix-react-sdk/pull/6374) + * 💭 Message bubble layout + [\#6291](https://github.com/matrix-org/matrix-react-sdk/pull/6291) + Fixes vector-im/element-web#4635, vector-im/element-web#17773 vector-im/element-web#16220 and vector-im/element-web#7687 + * Play only one audio file at a time + [\#6417](https://github.com/matrix-org/matrix-react-sdk/pull/6417) + Fixes vector-im/element-web#17439 + * Move download button for media to the action bar + [\#6386](https://github.com/matrix-org/matrix-react-sdk/pull/6386) + Fixes vector-im/element-web#17943 + * Improved display of one-to-one call history with summary boxes for each call + [\#6121](https://github.com/matrix-org/matrix-react-sdk/pull/6121) + Fixes vector-im/element-web#16409 + * Notification settings UI refresh + [\#6352](https://github.com/matrix-org/matrix-react-sdk/pull/6352) + Fixes vector-im/element-web#17782 + * Fix EventIndex double handling events and erroring + [\#6385](https://github.com/matrix-org/matrix-react-sdk/pull/6385) + Fixes vector-im/element-web#18008 + * Improve reply rendering + [\#3553](https://github.com/matrix-org/matrix-react-sdk/pull/3553) + Fixes vector-im/riot-web#9217, vector-im/riot-web#7633, vector-im/riot-web#7530, vector-im/riot-web#7169, vector-im/riot-web#7151, vector-im/riot-web#6692 vector-im/riot-web#6579 and vector-im/element-web#17440 + +## 🐛 Bug Fixes + * Fix CreateRoomDialog exploding when making public room outside of a space + [\#6493](https://github.com/matrix-org/matrix-react-sdk/pull/6493) + * Fix regression where registration would soft-crash on captcha + [\#6505](https://github.com/matrix-org/matrix-react-sdk/pull/6505) + Fixes vector-im/element-web#18284 + * only send join rule event if we have a join rule to put in it + [\#6517](https://github.com/matrix-org/matrix-react-sdk/pull/6517) + * Improve the new download button's discoverability and interactions. + [\#6510](https://github.com/matrix-org/matrix-react-sdk/pull/6510) + * Fix voice recording UI looking broken while microphone permissions are being requested. + [\#6479](https://github.com/matrix-org/matrix-react-sdk/pull/6479) + Fixes vector-im/element-web#18223 + * Match colors of room and user avatars in DMs + [\#6393](https://github.com/matrix-org/matrix-react-sdk/pull/6393) + Fixes vector-im/element-web#2449 + * Fix onPaste handler to work with copying files from Finder + [\#5389](https://github.com/matrix-org/matrix-react-sdk/pull/5389) + Fixes vector-im/element-web#15536 and vector-im/element-web#16255 + * Fix infinite pagination loop when offline + [\#6478](https://github.com/matrix-org/matrix-react-sdk/pull/6478) + Fixes vector-im/element-web#18242 + * Fix blurhash rounded corners missing regression + [\#6467](https://github.com/matrix-org/matrix-react-sdk/pull/6467) + Fixes vector-im/element-web#18110 + * Fix position of the space hierarchy spinner + [\#6462](https://github.com/matrix-org/matrix-react-sdk/pull/6462) + Fixes vector-im/element-web#18182 + * Fix display of image messages that lack thumbnails + [\#6456](https://github.com/matrix-org/matrix-react-sdk/pull/6456) + Fixes vector-im/element-web#18175 + * Fix crash with large audio files. + [\#6436](https://github.com/matrix-org/matrix-react-sdk/pull/6436) + Fixes vector-im/element-web#18149 + * Make diff colors in codeblocks more pleasant + [\#6355](https://github.com/matrix-org/matrix-react-sdk/pull/6355) + Fixes vector-im/element-web#17939 + * Show the correct audio file duration while loading the file. + [\#6435](https://github.com/matrix-org/matrix-react-sdk/pull/6435) + Fixes vector-im/element-web#18160 + * Fix various timeline settings not applying immediately. + [\#6261](https://github.com/matrix-org/matrix-react-sdk/pull/6261) + Fixes vector-im/element-web#17748 + * Fix issues with room list duplication + [\#6391](https://github.com/matrix-org/matrix-react-sdk/pull/6391) + Fixes vector-im/element-web#14508 + * Fix grecaptcha throwing useless error sometimes + [\#6401](https://github.com/matrix-org/matrix-react-sdk/pull/6401) + Fixes vector-im/element-web#15142 + * Update Emojibase and Twemoji and switch to IamCal (Slack-style) shortcodes + [\#6347](https://github.com/matrix-org/matrix-react-sdk/pull/6347) + Fixes vector-im/element-web#13857 and vector-im/element-web#13334 + * Respect compound emojis in default avatar initial generation + [\#6397](https://github.com/matrix-org/matrix-react-sdk/pull/6397) + Fixes vector-im/element-web#18040 + * Fix bug where the 'other homeserver' field in the server selection dialog would become briefly focus and then unfocus when clicked. + [\#6394](https://github.com/matrix-org/matrix-react-sdk/pull/6394) + Fixes vector-im/element-web#18031 + * Standardise spelling and casing of homeserver, identity server, and integration manager + [\#6365](https://github.com/matrix-org/matrix-react-sdk/pull/6365) + * Fix widgets not receiving decrypted events when they have permission. + [\#6371](https://github.com/matrix-org/matrix-react-sdk/pull/6371) + Fixes vector-im/element-web#17615 + * Prevent client hangs when calculating blurhashes + [\#6366](https://github.com/matrix-org/matrix-react-sdk/pull/6366) + Fixes vector-im/element-web#17945 + * Exclude state events from widgets reading room events + [\#6378](https://github.com/matrix-org/matrix-react-sdk/pull/6378) + * Cache feature_spaces\* flags to improve performance + [\#6381](https://github.com/matrix-org/matrix-react-sdk/pull/6381) + +Changes in [3.26.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.26.0) (2021-07-19) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.26.0-rc.1...v3.26.0) + + * Fix 'User' type import + [\#6376](https://github.com/matrix-org/matrix-react-sdk/pull/6376) + +Changes in [3.26.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.26.0-rc.1) (2021-07-14) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.25.0...v3.26.0-rc.1) + + * Fix voice messages in right panels + [\#6370](https://github.com/matrix-org/matrix-react-sdk/pull/6370) + * Use TileShape enum more universally + [\#6369](https://github.com/matrix-org/matrix-react-sdk/pull/6369) + * Translations update from Weblate + [\#6373](https://github.com/matrix-org/matrix-react-sdk/pull/6373) + * Hide world readable history option in encrypted rooms + [\#5947](https://github.com/matrix-org/matrix-react-sdk/pull/5947) + * Make the Image View buttons easier to hit + [\#6372](https://github.com/matrix-org/matrix-react-sdk/pull/6372) + * Reorder buttons in the Image View + [\#6368](https://github.com/matrix-org/matrix-react-sdk/pull/6368) + * Add VS Code to gitignore + [\#6367](https://github.com/matrix-org/matrix-react-sdk/pull/6367) + * Fix inviter exploding due to member being null + [\#6362](https://github.com/matrix-org/matrix-react-sdk/pull/6362) + * Increase sample count in voice message thumbnail + [\#6359](https://github.com/matrix-org/matrix-react-sdk/pull/6359) + * Improve arraySeed utility + [\#6360](https://github.com/matrix-org/matrix-react-sdk/pull/6360) + * Convert FontManager to TS and stub it out for tests + [\#6358](https://github.com/matrix-org/matrix-react-sdk/pull/6358) + * Adjust recording waveform behaviour for voice messages + [\#6357](https://github.com/matrix-org/matrix-react-sdk/pull/6357) + * Do not honor string power levels + [\#6245](https://github.com/matrix-org/matrix-react-sdk/pull/6245) + * Add alias and directory customisation points + [\#6343](https://github.com/matrix-org/matrix-react-sdk/pull/6343) + * Fix multiinviter user already in room and clean up code + [\#6354](https://github.com/matrix-org/matrix-react-sdk/pull/6354) + * Fix right panel not closing user info when changing rooms + [\#6341](https://github.com/matrix-org/matrix-react-sdk/pull/6341) + * Quit sticker picker on m.sticker + [\#5679](https://github.com/matrix-org/matrix-react-sdk/pull/5679) + * Don't autodetect language in inline code blocks + [\#6350](https://github.com/matrix-org/matrix-react-sdk/pull/6350) + * Make ghost button background transparent + [\#6331](https://github.com/matrix-org/matrix-react-sdk/pull/6331) + * only consider valid & loaded url previews for show N more prompt + [\#6346](https://github.com/matrix-org/matrix-react-sdk/pull/6346) + * Extract MXCs from _matrix/media/r0/ URLs for inline images in messages + [\#6335](https://github.com/matrix-org/matrix-react-sdk/pull/6335) + * Fix small visual regression with the site name on url previews + [\#6342](https://github.com/matrix-org/matrix-react-sdk/pull/6342) + * Make PIP CallView draggable/movable + [\#5952](https://github.com/matrix-org/matrix-react-sdk/pull/5952) + * Convert VoiceUserSettingsTab to TS + [\#6340](https://github.com/matrix-org/matrix-react-sdk/pull/6340) + * Simplify typescript definition for Modernizr + [\#6339](https://github.com/matrix-org/matrix-react-sdk/pull/6339) + * Remember the last used server for room directory searches + [\#6322](https://github.com/matrix-org/matrix-react-sdk/pull/6322) + * Focus composer after reacting + [\#6332](https://github.com/matrix-org/matrix-react-sdk/pull/6332) + * Fix bug which prevented more than one event getting pinned + [\#6336](https://github.com/matrix-org/matrix-react-sdk/pull/6336) + * Make DeviceListener also update on megolm key in SSSS + [\#6337](https://github.com/matrix-org/matrix-react-sdk/pull/6337) + * Improve URL previews + [\#6326](https://github.com/matrix-org/matrix-react-sdk/pull/6326) + * Don't close settings dialog when opening spaces feedback prompt + [\#6334](https://github.com/matrix-org/matrix-react-sdk/pull/6334) + * Update import location for types + [\#6330](https://github.com/matrix-org/matrix-react-sdk/pull/6330) + * Improve blurhash rendering performance + [\#6329](https://github.com/matrix-org/matrix-react-sdk/pull/6329) + * Use a proper color scheme for codeblocks + [\#6320](https://github.com/matrix-org/matrix-react-sdk/pull/6320) + * Burn `sdk.getComponent()` with 🔥 + [\#6308](https://github.com/matrix-org/matrix-react-sdk/pull/6308) + * Fix instances of the Edit Message Composer's save button being wrongly + disabled + [\#6307](https://github.com/matrix-org/matrix-react-sdk/pull/6307) + * Do not generate a lockfile when running in CI + [\#6327](https://github.com/matrix-org/matrix-react-sdk/pull/6327) + * Update lockfile with correct dependencies + [\#6324](https://github.com/matrix-org/matrix-react-sdk/pull/6324) + * Clarify the keys we use when submitting rageshakes + [\#6321](https://github.com/matrix-org/matrix-react-sdk/pull/6321) + * Fix ImageView context menu + [\#6318](https://github.com/matrix-org/matrix-react-sdk/pull/6318) + * TypeScript migration + [\#6315](https://github.com/matrix-org/matrix-react-sdk/pull/6315) + * Move animation to compositor + [\#6310](https://github.com/matrix-org/matrix-react-sdk/pull/6310) + * Reorganize preferences + [\#5742](https://github.com/matrix-org/matrix-react-sdk/pull/5742) + * Fix being able to un-rotate images + [\#6313](https://github.com/matrix-org/matrix-react-sdk/pull/6313) + * Fix icon size in passphrase prompt + [\#6312](https://github.com/matrix-org/matrix-react-sdk/pull/6312) + * Use sleep & defer from js-sdk instead of duplicating it + [\#6305](https://github.com/matrix-org/matrix-react-sdk/pull/6305) + * Convert EventTimeline, EventTimelineSet and TimelineWindow to TS + [\#6295](https://github.com/matrix-org/matrix-react-sdk/pull/6295) + * Comply with new member-delimiter-style rule + [\#6306](https://github.com/matrix-org/matrix-react-sdk/pull/6306) + * Fix Test Linting + [\#6304](https://github.com/matrix-org/matrix-react-sdk/pull/6304) + * Convert Markdown to TypeScript + [\#6303](https://github.com/matrix-org/matrix-react-sdk/pull/6303) + * Convert RoomHeader to TS + [\#6302](https://github.com/matrix-org/matrix-react-sdk/pull/6302) + * Prevent RoomDirectory from exploding when filterString is wrongly nulled + [\#6296](https://github.com/matrix-org/matrix-react-sdk/pull/6296) + * Add support for blurhash (MSC2448) + [\#5099](https://github.com/matrix-org/matrix-react-sdk/pull/5099) + * Remove rateLimitedFunc + [\#6300](https://github.com/matrix-org/matrix-react-sdk/pull/6300) + * Convert some Key Verification classes to TypeScript + [\#6299](https://github.com/matrix-org/matrix-react-sdk/pull/6299) + * Typescript conversion of Composer components and more + [\#6292](https://github.com/matrix-org/matrix-react-sdk/pull/6292) + * Upgrade browserlist target versions + [\#6298](https://github.com/matrix-org/matrix-react-sdk/pull/6298) + * Fix browser crashing when searching for a malformed HTML tag + [\#6297](https://github.com/matrix-org/matrix-react-sdk/pull/6297) + * Add custom audio player + [\#6264](https://github.com/matrix-org/matrix-react-sdk/pull/6264) + * Lint MXC APIs to centralise access + [\#6293](https://github.com/matrix-org/matrix-react-sdk/pull/6293) + * Remove reminescent references to the tinter + [\#6290](https://github.com/matrix-org/matrix-react-sdk/pull/6290) + * More js-sdk type consolidation + [\#6263](https://github.com/matrix-org/matrix-react-sdk/pull/6263) + * Convert MessagePanel, TimelinePanel, ScrollPanel, and more to Typescript + [\#6243](https://github.com/matrix-org/matrix-react-sdk/pull/6243) + * Migrate to `eslint-plugin-matrix-org` + [\#6285](https://github.com/matrix-org/matrix-react-sdk/pull/6285) + * Avoid cyclic dependencies by moving watchers out of constructor + [\#6287](https://github.com/matrix-org/matrix-react-sdk/pull/6287) + * Add spacing between toast buttons with cross browser support in mind + [\#6284](https://github.com/matrix-org/matrix-react-sdk/pull/6284) + * Deprecate Tinter and TintableSVG + [\#6279](https://github.com/matrix-org/matrix-react-sdk/pull/6279) + * Migrate FilePanel to TypeScript + [\#6283](https://github.com/matrix-org/matrix-react-sdk/pull/6283) + +Changes in [3.25.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.25.0) (2021-07-05) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.25.0-rc.1...v3.25.0) + + * Remove reminescent references to the tinter + [\#6316](https://github.com/matrix-org/matrix-react-sdk/pull/6316) + * Update to released version of js-sdk + +Changes in [3.25.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.25.0-rc.1) (2021-06-29) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.24.0...v3.25.0-rc.1) + + * Update to js-sdk v12.0.1-rc.1 + * Translations update from Weblate + [\#6286](https://github.com/matrix-org/matrix-react-sdk/pull/6286) + * Fix back button on user info card after clicking a permalink + [\#6277](https://github.com/matrix-org/matrix-react-sdk/pull/6277) + * Group ACLs with MELS + [\#6280](https://github.com/matrix-org/matrix-react-sdk/pull/6280) + * Fix editState not getting passed through + [\#6282](https://github.com/matrix-org/matrix-react-sdk/pull/6282) + * Migrate message context menu to IconizedContextMenu + [\#5671](https://github.com/matrix-org/matrix-react-sdk/pull/5671) + * Improve audio recording performance + [\#6240](https://github.com/matrix-org/matrix-react-sdk/pull/6240) + * Fix multiple timeline panels handling composer and edit events + [\#6278](https://github.com/matrix-org/matrix-react-sdk/pull/6278) + * Let m.notice messages mark a room as unread + [\#6281](https://github.com/matrix-org/matrix-react-sdk/pull/6281) + * Removes the override on the Bubble Container + [\#5953](https://github.com/matrix-org/matrix-react-sdk/pull/5953) + * Fix IRC layout regressions + [\#6193](https://github.com/matrix-org/matrix-react-sdk/pull/6193) + * Fix trashcan.svg by exporting it with its viewbox + [\#6248](https://github.com/matrix-org/matrix-react-sdk/pull/6248) + * Fix tiny scrollbar dot on chrome/electron in Forward Dialog + [\#6276](https://github.com/matrix-org/matrix-react-sdk/pull/6276) + * Upgrade puppeteer to use newer version of Chrome + [\#6268](https://github.com/matrix-org/matrix-react-sdk/pull/6268) + * Make toast dismiss button less prominent + [\#6275](https://github.com/matrix-org/matrix-react-sdk/pull/6275) + * Encrypt the voice message file if needed + [\#6269](https://github.com/matrix-org/matrix-react-sdk/pull/6269) + * Fix hyper-precise presence + [\#6270](https://github.com/matrix-org/matrix-react-sdk/pull/6270) + * Fix issues around private spaces, including previewable + [\#6265](https://github.com/matrix-org/matrix-react-sdk/pull/6265) + * Make _pinned messages_ in `m.room.pinned_events` event clickable + [\#6257](https://github.com/matrix-org/matrix-react-sdk/pull/6257) + * Fix space avatar management layout being broken + [\#6266](https://github.com/matrix-org/matrix-react-sdk/pull/6266) + * Convert EntityTile, MemberTile and PresenceLabel to TS + [\#6251](https://github.com/matrix-org/matrix-react-sdk/pull/6251) + * Fix UserInfo not working when rendered without a room + [\#6260](https://github.com/matrix-org/matrix-react-sdk/pull/6260) + * Update membership reason handling, including leave reason displaying + [\#6253](https://github.com/matrix-org/matrix-react-sdk/pull/6253) + * Consolidate types with js-sdk changes + [\#6220](https://github.com/matrix-org/matrix-react-sdk/pull/6220) + * Fix edit history modal + [\#6258](https://github.com/matrix-org/matrix-react-sdk/pull/6258) + * Convert MemberList to TS + [\#6249](https://github.com/matrix-org/matrix-react-sdk/pull/6249) + * Fix two PRs duplicating the css attribute + [\#6259](https://github.com/matrix-org/matrix-react-sdk/pull/6259) + * Improve invite error messages in InviteDialog for room invites + [\#6201](https://github.com/matrix-org/matrix-react-sdk/pull/6201) + * Fix invite dialog being cut off when it has limited results + [\#6256](https://github.com/matrix-org/matrix-react-sdk/pull/6256) + * Fix pinning event in a room which hasn't had events pinned in before + [\#6255](https://github.com/matrix-org/matrix-react-sdk/pull/6255) + * Allow modal widget buttons to be disabled when the modal opens + [\#6178](https://github.com/matrix-org/matrix-react-sdk/pull/6178) + * Decrease e2e shield fill mask size so that it doesn't overlap + [\#6250](https://github.com/matrix-org/matrix-react-sdk/pull/6250) + * Dial Pad UI bug fixes + [\#5786](https://github.com/matrix-org/matrix-react-sdk/pull/5786) + * Simple handling of mid-call output changes + [\#6247](https://github.com/matrix-org/matrix-react-sdk/pull/6247) + * Improve ForwardDialog performance by using TruncatedList + [\#6228](https://github.com/matrix-org/matrix-react-sdk/pull/6228) + * Fix dependency and lockfile mismatch + [\#6246](https://github.com/matrix-org/matrix-react-sdk/pull/6246) + * Improve room directory click behaviour + [\#6234](https://github.com/matrix-org/matrix-react-sdk/pull/6234) + * Fix keyboard accessibility of the space panel + [\#6239](https://github.com/matrix-org/matrix-react-sdk/pull/6239) + * Add ways to manage addresses for Spaces + [\#6151](https://github.com/matrix-org/matrix-react-sdk/pull/6151) + * Hide communities invites and the community autocompleter when Spaces on + [\#6244](https://github.com/matrix-org/matrix-react-sdk/pull/6244) + * Convert bunch of files to TS + [\#6241](https://github.com/matrix-org/matrix-react-sdk/pull/6241) + * Open local addresses section by default when there are no existing local + addresses + [\#6179](https://github.com/matrix-org/matrix-react-sdk/pull/6179) + * Allow reordering of the space panel via Drag and Drop + [\#6137](https://github.com/matrix-org/matrix-react-sdk/pull/6137) + * Replace drag and drop mechanism in communities with something simpler + [\#6134](https://github.com/matrix-org/matrix-react-sdk/pull/6134) + * EventTilePreview fixes + [\#6000](https://github.com/matrix-org/matrix-react-sdk/pull/6000) + * Upgrade @types/react and @types/react-dom + [\#6233](https://github.com/matrix-org/matrix-react-sdk/pull/6233) + * Fix type error in the SpaceStore + [\#6242](https://github.com/matrix-org/matrix-react-sdk/pull/6242) + * Add experimental options to the Spaces beta + [\#6199](https://github.com/matrix-org/matrix-react-sdk/pull/6199) + * Consolidate types with js-sdk changes + [\#6215](https://github.com/matrix-org/matrix-react-sdk/pull/6215) + * Fix branch matching for Buildkite + [\#6236](https://github.com/matrix-org/matrix-react-sdk/pull/6236) + * Migrate SearchBar to TypeScript + [\#6230](https://github.com/matrix-org/matrix-react-sdk/pull/6230) + * Add support to keyboard shortcuts dialog for [digits] + [\#6088](https://github.com/matrix-org/matrix-react-sdk/pull/6088) + * Fix modal opening race condition + [\#6238](https://github.com/matrix-org/matrix-react-sdk/pull/6238) + * Deprecate FormButton in favour of AccessibleButton + [\#6229](https://github.com/matrix-org/matrix-react-sdk/pull/6229) + * Add PR template + [\#6216](https://github.com/matrix-org/matrix-react-sdk/pull/6216) + * Prefer canonical aliases while autocompleting rooms + [\#6222](https://github.com/matrix-org/matrix-react-sdk/pull/6222) + * Fix quote button + [\#6232](https://github.com/matrix-org/matrix-react-sdk/pull/6232) + * Restore branch matching support for GitHub Actions e2e tests + [\#6224](https://github.com/matrix-org/matrix-react-sdk/pull/6224) + * Fix View Source accessing renamed private field on MatrixEvent + [\#6225](https://github.com/matrix-org/matrix-react-sdk/pull/6225) + * Fix ConfirmUserActionDialog returning an input field rather than text + [\#6219](https://github.com/matrix-org/matrix-react-sdk/pull/6219) + * Revert "Partially restore immutable event objects at the rendering layer" + [\#6221](https://github.com/matrix-org/matrix-react-sdk/pull/6221) + * Add jq to e2e tests Dockerfile + [\#6218](https://github.com/matrix-org/matrix-react-sdk/pull/6218) + * Partially restore immutable event objects at the rendering layer + [\#6196](https://github.com/matrix-org/matrix-react-sdk/pull/6196) + * Update MSC number references for voice messages + [\#6197](https://github.com/matrix-org/matrix-react-sdk/pull/6197) + * Fix phase enum usage in JS modules as well + [\#6214](https://github.com/matrix-org/matrix-react-sdk/pull/6214) + * Migrate some dialogs to TypeScript + [\#6185](https://github.com/matrix-org/matrix-react-sdk/pull/6185) + * Typescript fixes due to MatrixEvent being TSified + [\#6208](https://github.com/matrix-org/matrix-react-sdk/pull/6208) + * Allow click-to-ping, quote & emoji picker for edit composer too + [\#5858](https://github.com/matrix-org/matrix-react-sdk/pull/5858) + * Add call silencing + [\#6082](https://github.com/matrix-org/matrix-react-sdk/pull/6082) + * Fix types in SlashCommands + [\#6207](https://github.com/matrix-org/matrix-react-sdk/pull/6207) + * Benchmark multiple common user scenario + [\#6190](https://github.com/matrix-org/matrix-react-sdk/pull/6190) + * Fix forward dialog message preview display names + [\#6204](https://github.com/matrix-org/matrix-react-sdk/pull/6204) + * Remove stray bullet point in reply preview + [\#6206](https://github.com/matrix-org/matrix-react-sdk/pull/6206) + * Stop requesting null next replies from the server + [\#6203](https://github.com/matrix-org/matrix-react-sdk/pull/6203) + * Fix soft crash caused by a broken shouldComponentUpdate + [\#6202](https://github.com/matrix-org/matrix-react-sdk/pull/6202) + * Keep composer reply when scrolling away from a highlighted event + [\#6200](https://github.com/matrix-org/matrix-react-sdk/pull/6200) + * Cache virtual/native room mappings when they're created + [\#6194](https://github.com/matrix-org/matrix-react-sdk/pull/6194) + * Disable comment-on-alert + [\#6191](https://github.com/matrix-org/matrix-react-sdk/pull/6191) + * Bump postcss from 7.0.35 to 7.0.36 + [\#6195](https://github.com/matrix-org/matrix-react-sdk/pull/6195) + +Changes in [3.24.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.24.0) (2021-06-21) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.24.0-rc.1...v3.24.0) + + * Upgrade to JS SDK 12.0.0 + * [Release] Keep composer reply when scrolling away from a highlighted event + [\#6211](https://github.com/matrix-org/matrix-react-sdk/pull/6211) + * [Release] Remove stray bullet point in reply preview + [\#6210](https://github.com/matrix-org/matrix-react-sdk/pull/6210) + * [Release] Stop requesting null next replies from the server + [\#6209](https://github.com/matrix-org/matrix-react-sdk/pull/6209) + +Changes in [3.24.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.24.0-rc.1) (2021-06-15) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.23.0...v3.24.0-rc.1) + + * Upgrade to JS SDK 12.0.0-rc.1 + * Translations update from Weblate + [\#6192](https://github.com/matrix-org/matrix-react-sdk/pull/6192) + * Disable comment-on-alert for PR coming from a fork + [\#6189](https://github.com/matrix-org/matrix-react-sdk/pull/6189) + * Add JS benchmark tracking in CI + [\#6177](https://github.com/matrix-org/matrix-react-sdk/pull/6177) + * Upgrade matrix-react-test-utils for React 17 peer deps + [\#6187](https://github.com/matrix-org/matrix-react-sdk/pull/6187) + * Fix display name overlaps on the IRC layout + [\#6186](https://github.com/matrix-org/matrix-react-sdk/pull/6186) + * Small fixes to the spaces experience + [\#6184](https://github.com/matrix-org/matrix-react-sdk/pull/6184) + * Add footer and privacy note to the start dm dialog + [\#6111](https://github.com/matrix-org/matrix-react-sdk/pull/6111) + * Format mxids when disambiguation needed + [\#5880](https://github.com/matrix-org/matrix-react-sdk/pull/5880) + * Move various createRoom types to the js-sdk + [\#6183](https://github.com/matrix-org/matrix-react-sdk/pull/6183) + * Fix HTML tag for Event Tile when not rendered in a list + [\#6175](https://github.com/matrix-org/matrix-react-sdk/pull/6175) + * Remove legacy polyfills and unused dependencies + [\#6176](https://github.com/matrix-org/matrix-react-sdk/pull/6176) + * Fix buggy hovering/selecting of event tiles + [\#6173](https://github.com/matrix-org/matrix-react-sdk/pull/6173) + * Add room intro warning when e2ee is not enabled + [\#5929](https://github.com/matrix-org/matrix-react-sdk/pull/5929) + * Migrate end to end tests to GitHub actions + [\#6156](https://github.com/matrix-org/matrix-react-sdk/pull/6156) + * Fix expanding last collapsed sticky session when zoomed in + [\#6171](https://github.com/matrix-org/matrix-react-sdk/pull/6171) + * ⚛️ Upgrade to React@17 + [\#6165](https://github.com/matrix-org/matrix-react-sdk/pull/6165) + * Revert refreshStickyHeaders optimisations + [\#6168](https://github.com/matrix-org/matrix-react-sdk/pull/6168) + * Add logging for which rooms calls are in + [\#6170](https://github.com/matrix-org/matrix-react-sdk/pull/6170) + * Restore read receipt animation from event to event + [\#6169](https://github.com/matrix-org/matrix-react-sdk/pull/6169) + * Restore copy button icon when sharing permalink + [\#6166](https://github.com/matrix-org/matrix-react-sdk/pull/6166) + * Restore Page Up/Down key bindings when focusing the composer + [\#6167](https://github.com/matrix-org/matrix-react-sdk/pull/6167) + * Timeline rendering optimizations + [\#6143](https://github.com/matrix-org/matrix-react-sdk/pull/6143) + * Bump css-what from 5.0.0 to 5.0.1 + [\#6164](https://github.com/matrix-org/matrix-react-sdk/pull/6164) + * Bump ws from 6.2.1 to 6.2.2 in /test/end-to-end-tests + [\#6145](https://github.com/matrix-org/matrix-react-sdk/pull/6145) + * Bump trim-newlines from 3.0.0 to 3.0.1 + [\#6163](https://github.com/matrix-org/matrix-react-sdk/pull/6163) + * Fix upgrade to element home button in top left menu + [\#6162](https://github.com/matrix-org/matrix-react-sdk/pull/6162) + * Fix unpinning of pinned messages and panel empty state + [\#6140](https://github.com/matrix-org/matrix-react-sdk/pull/6140) + * Better handling for widgets that fail to load + [\#6161](https://github.com/matrix-org/matrix-react-sdk/pull/6161) + * Improved forwarding UI + [\#5999](https://github.com/matrix-org/matrix-react-sdk/pull/5999) + * Fixes for sharing room links + [\#6118](https://github.com/matrix-org/matrix-react-sdk/pull/6118) + * Fix setting watchers + [\#6160](https://github.com/matrix-org/matrix-react-sdk/pull/6160) + * Fix Stickerpicker context menu + [\#6152](https://github.com/matrix-org/matrix-react-sdk/pull/6152) + * Add warning to private space creation flow + [\#6155](https://github.com/matrix-org/matrix-react-sdk/pull/6155) + * Add prop to alwaysShowTimestamps on TimelinePanel + [\#6159](https://github.com/matrix-org/matrix-react-sdk/pull/6159) + * Fix notif panel timestamp padding + [\#6157](https://github.com/matrix-org/matrix-react-sdk/pull/6157) + * Fixes and refactoring for the ImageView + [\#6149](https://github.com/matrix-org/matrix-react-sdk/pull/6149) + * Fix timestamps + [\#6148](https://github.com/matrix-org/matrix-react-sdk/pull/6148) + * Make it easier to pan images in the lightbox + [\#6147](https://github.com/matrix-org/matrix-react-sdk/pull/6147) + * Fix scroll token for EventTile and EventListSummary node type + [\#6154](https://github.com/matrix-org/matrix-react-sdk/pull/6154) + * Convert bunch of things to Typescript + [\#6153](https://github.com/matrix-org/matrix-react-sdk/pull/6153) + * Lint the typescript tests + [\#6142](https://github.com/matrix-org/matrix-react-sdk/pull/6142) + * Fix jumping to bottom without a highlighted event + [\#6146](https://github.com/matrix-org/matrix-react-sdk/pull/6146) + * Repair event status position in timeline + [\#6141](https://github.com/matrix-org/matrix-react-sdk/pull/6141) + * Adapt for js-sdk MatrixClient conversion to TS + [\#6132](https://github.com/matrix-org/matrix-react-sdk/pull/6132) + * Improve pinned messages in Labs + [\#6096](https://github.com/matrix-org/matrix-react-sdk/pull/6096) + * Map phone number lookup results to their native rooms + [\#6136](https://github.com/matrix-org/matrix-react-sdk/pull/6136) + * Fix mx_Event containment rules and empty read avatar row + [\#6138](https://github.com/matrix-org/matrix-react-sdk/pull/6138) + * Improve switch room rendering + [\#6079](https://github.com/matrix-org/matrix-react-sdk/pull/6079) + * Add CSS containment rules for shorter reflow operations + [\#6127](https://github.com/matrix-org/matrix-react-sdk/pull/6127) + * ignore hash/fragment when de-duplicating links for url previews + [\#6135](https://github.com/matrix-org/matrix-react-sdk/pull/6135) + * Clicking jump to bottom resets room hash + [\#5823](https://github.com/matrix-org/matrix-react-sdk/pull/5823) + * Use passive option for scroll handlers + [\#6113](https://github.com/matrix-org/matrix-react-sdk/pull/6113) + * Optimise memberSort performance for large list + [\#6130](https://github.com/matrix-org/matrix-react-sdk/pull/6130) + * Tweak event border radius to match action bar + [\#6133](https://github.com/matrix-org/matrix-react-sdk/pull/6133) + * Log when we ignore a second call in a room + [\#6131](https://github.com/matrix-org/matrix-react-sdk/pull/6131) + * Performance monitoring measurements + [\#6041](https://github.com/matrix-org/matrix-react-sdk/pull/6041) + +Changes in [3.23.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.23.0) (2021-06-07) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.23.0-rc.1...v3.23.0) + + * Upgrade to JS SDK 11.2.0 + * [Release] Fix notif panel timestamp padding + [\#6158](https://github.com/matrix-org/matrix-react-sdk/pull/6158) + +Changes in [3.23.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.23.0-rc.1) (2021-06-01) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.22.0...v3.23.0-rc.1) + + * Upgrade to JS SDK 11.2.0-rc.1 + * Translations update from Weblate + [\#6128](https://github.com/matrix-org/matrix-react-sdk/pull/6128) + * Fix all DMs wrongly appearing in room list when `m.direct` is changed + [\#6122](https://github.com/matrix-org/matrix-react-sdk/pull/6122) + * Update way of checking for registration disabled + [\#6123](https://github.com/matrix-org/matrix-react-sdk/pull/6123) + * Fix the ability to remove avatar from a space via settings + [\#6126](https://github.com/matrix-org/matrix-react-sdk/pull/6126) + * Switch to stable endpoint/fields for MSC2858 + [\#6125](https://github.com/matrix-org/matrix-react-sdk/pull/6125) + * Clear stored editor state when canceling editing using a shortcut + [\#6117](https://github.com/matrix-org/matrix-react-sdk/pull/6117) + * Respect newlines in space topics + [\#6124](https://github.com/matrix-org/matrix-react-sdk/pull/6124) + * Add url param `defaultUsername` to prefill the login username field + [\#5674](https://github.com/matrix-org/matrix-react-sdk/pull/5674) + * Bump ws from 7.4.2 to 7.4.6 + [\#6115](https://github.com/matrix-org/matrix-react-sdk/pull/6115) + * Sticky headers repositioning without layout trashing + [\#6110](https://github.com/matrix-org/matrix-react-sdk/pull/6110) + * Handle user_busy in voip calls + [\#6112](https://github.com/matrix-org/matrix-react-sdk/pull/6112) + * Avoid showing warning modals from the invite dialog after it unmounts + [\#6105](https://github.com/matrix-org/matrix-react-sdk/pull/6105) + * Fix misleading child counts in spaces + [\#6109](https://github.com/matrix-org/matrix-react-sdk/pull/6109) + * Close creation menu when expanding space panel via expand hierarchy + [\#6090](https://github.com/matrix-org/matrix-react-sdk/pull/6090) + * Prevent having duplicates in pending room state + [\#6108](https://github.com/matrix-org/matrix-react-sdk/pull/6108) + * Update reactions row on event decryption + [\#6106](https://github.com/matrix-org/matrix-react-sdk/pull/6106) + * Destroy playback instance on voice message unmount + [\#6101](https://github.com/matrix-org/matrix-react-sdk/pull/6101) + * Fix message preview not up to date + [\#6102](https://github.com/matrix-org/matrix-react-sdk/pull/6102) + * Convert some Flow typed files to TS (round 2) + [\#6076](https://github.com/matrix-org/matrix-react-sdk/pull/6076) + * Remove unused middlePanelResized event listener + [\#6086](https://github.com/matrix-org/matrix-react-sdk/pull/6086) + * Fix accessing currentState on an invalid joinedRoom + [\#6100](https://github.com/matrix-org/matrix-react-sdk/pull/6100) + * Remove Promise allSettled polyfill as js-sdk uses it directly + [\#6097](https://github.com/matrix-org/matrix-react-sdk/pull/6097) + * Prevent DecoratedRoomAvatar to update its state for the same value + [\#6099](https://github.com/matrix-org/matrix-react-sdk/pull/6099) + * Skip generatePreview if event is not part of the live timeline + [\#6098](https://github.com/matrix-org/matrix-react-sdk/pull/6098) + * fix sticky headers when results num get displayed + [\#6095](https://github.com/matrix-org/matrix-react-sdk/pull/6095) + * Improve addEventsToTimeline performance scoping WhoIsTypingTile::setState + [\#6094](https://github.com/matrix-org/matrix-react-sdk/pull/6094) + * Safeguards to prevent layout trashing for window dimensions + [\#6092](https://github.com/matrix-org/matrix-react-sdk/pull/6092) + * Use local room state to render space hierarchy if the room is known + [\#6089](https://github.com/matrix-org/matrix-react-sdk/pull/6089) + * Add spinner in UserMenu to list pending long running actions + [\#6085](https://github.com/matrix-org/matrix-react-sdk/pull/6085) + * Stop overscroll in Firefox Nightly for macOS + [\#6093](https://github.com/matrix-org/matrix-react-sdk/pull/6093) + * Move SettingsStore watchers/monitors over to ES6 maps for performance + [\#6063](https://github.com/matrix-org/matrix-react-sdk/pull/6063) + * Bump libolm version. + [\#6080](https://github.com/matrix-org/matrix-react-sdk/pull/6080) + * Improve styling of the message action bar + [\#6066](https://github.com/matrix-org/matrix-react-sdk/pull/6066) + * Improve explore rooms when no results are found + [\#6070](https://github.com/matrix-org/matrix-react-sdk/pull/6070) + * Remove logo spinner + [\#6078](https://github.com/matrix-org/matrix-react-sdk/pull/6078) + * Fix add reaction prompt showing even when user is not joined to room + [\#6073](https://github.com/matrix-org/matrix-react-sdk/pull/6073) + * Vectorize spinners + [\#5680](https://github.com/matrix-org/matrix-react-sdk/pull/5680) + * Fix handling of via servers for suggested rooms + [\#6077](https://github.com/matrix-org/matrix-react-sdk/pull/6077) + * Upgrade showChatEffects to room-level setting exposure + [\#6075](https://github.com/matrix-org/matrix-react-sdk/pull/6075) + * Delete RoomView dead code + [\#6071](https://github.com/matrix-org/matrix-react-sdk/pull/6071) + * Reduce noise in tests + [\#6074](https://github.com/matrix-org/matrix-react-sdk/pull/6074) + * Fix room name issues in right panel summary card + [\#6069](https://github.com/matrix-org/matrix-react-sdk/pull/6069) + * Cache normalized room name + [\#6072](https://github.com/matrix-org/matrix-react-sdk/pull/6072) + * Update MemberList to reflect changes for invite permission change + [\#6061](https://github.com/matrix-org/matrix-react-sdk/pull/6061) + * Delete RoomView dead code + [\#6065](https://github.com/matrix-org/matrix-react-sdk/pull/6065) + * Show subspace rooms count even if it is 0 for consistency + [\#6067](https://github.com/matrix-org/matrix-react-sdk/pull/6067) + Changes in [3.22.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.22.0) (2021-05-24) ===================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.22.0-rc.1...v3.22.0) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.md similarity index 71% rename from CONTRIBUTING.rst rename to CONTRIBUTING.md index f7c8c8b1c5..f0ca3eb8a7 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ Contributing code to The React SDK ================================== -matrix-react-sdk follows the same pattern as https://github.com/matrix-org/matrix-js-sdk/blob/master/CONTRIBUTING.rst +matrix-react-sdk follows the same pattern as https://github.com/matrix-org/matrix-js-sdk/blob/master/CONTRIBUTING.md diff --git a/README.md b/README.md index b3e96ef001..4588a0586e 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ All code lands on the `develop` branch - `master` is only used for stable releas **Please file PRs against `develop`!!** Please follow the standard Matrix contributor's guide: -https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.rst +https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md Please follow the Matrix JS/React code style as per: https://github.com/matrix-org/matrix-react-sdk/blob/master/code_style.md @@ -48,7 +48,7 @@ Code should be committed as follows: * CSS: https://github.com/matrix-org/matrix-react-sdk/tree/master/res/css * Theme specific CSS & resources: https://github.com/matrix-org/matrix-react-sdk/tree/master/res/themes -React components in matrix-react-sdk are come in two different flavours: +React components in matrix-react-sdk come in two different flavours: 'structures' and 'views'. Structures are stateful components which handle the more complicated business logic of the app, delegating their actual presentation rendering to stateless 'view' components. For instance, the RoomView component diff --git a/__mocks__/FontManager.js b/__mocks__/FontManager.js new file mode 100644 index 0000000000..41eab4bf94 --- /dev/null +++ b/__mocks__/FontManager.js @@ -0,0 +1,6 @@ +// Stub out FontManager for tests as it doesn't validate anything we don't already know given +// our fixed test environment and it requires the installation of node-canvas. + +module.exports = { + fixupColorFonts: () => Promise.resolve(), +}; diff --git a/__mocks__/workerMock.js b/__mocks__/workerMock.js new file mode 100644 index 0000000000..6ee585673e --- /dev/null +++ b/__mocks__/workerMock.js @@ -0,0 +1 @@ +module.exports = jest.fn(); diff --git a/babel.config.js b/babel.config.js index 0a3a34a391..f00e83652c 100644 --- a/babel.config.js +++ b/babel.config.js @@ -10,7 +10,6 @@ module.exports = { ], }], "@babel/preset-typescript", - "@babel/preset-flow", "@babel/preset-react", ], "plugins": [ @@ -19,7 +18,6 @@ module.exports = { "@babel/plugin-proposal-numeric-separator", "@babel/plugin-proposal-class-properties", "@babel/plugin-proposal-object-rest-spread", - "@babel/plugin-transform-flow-comments", "@babel/plugin-syntax-dynamic-import", "@babel/plugin-transform-runtime", ], diff --git a/package.json b/package.json index 13047b69cf..46ff26bf32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.22.0", + "version": "3.31.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -25,9 +25,9 @@ "bin": { "reskindex": "scripts/reskindex.js" }, - "main": "./src/index.js", - "matrix_src_main": "./src/index.js", - "matrix_lib_main": "./lib/index.js", + "main": "./src/index.ts", + "matrix_src_main": "./src/index.ts", + "matrix_lib_main": "./lib/index.ts", "matrix_lib_typings": "./lib/index.d.ts", "scripts": { "prepublishOnly": "yarn build", @@ -45,7 +45,8 @@ "start:all": "concurrently --kill-others-on-fail --prefix \"{time} [{name}]\" -n build,reskindex \"yarn start:build\" \"yarn reskindex:watch\"", "start:build": "babel src -w -s -d lib --verbose --extensions \".ts,.js\"", "lint": "yarn lint:types && yarn lint:js && yarn lint:style", - "lint:js": "eslint --max-warnings 0 --ignore-path .eslintignore.errorfiles src test", + "lint:js": "eslint --max-warnings 0 src test", + "lint:js-fix": "eslint --fix src test", "lint:types": "tsc --noEmit --jsx react", "lint:style": "stylelint 'res/css/**/*.scss'", "test": "jest", @@ -54,8 +55,10 @@ }, "dependencies": { "@babel/runtime": "^7.12.5", + "@sentry/browser": "^6.11.0", + "@sentry/tracing": "^6.11.0", "await-lock": "^2.1.0", - "blueimp-canvas-to-blob": "^3.28.0", + "blurhash": "^1.1.3", "browser-encrypt-attachment": "^0.3.0", "browser-request": "^0.3.3", "cheerio": "^1.0.0-rc.9", @@ -64,8 +67,8 @@ "counterpart": "^0.18.6", "diff-dom": "^4.2.2", "diff-match-patch": "^1.0.5", - "emojibase-data": "^5.1.1", - "emojibase-regex": "^4.1.1", + "emojibase-data": "^6.2.0", + "emojibase-regex": "^5.1.3", "escape-html": "^1.0.3", "file-saver": "^2.0.5", "filesize": "6.1.0", @@ -76,30 +79,31 @@ "highlight.js": "^10.5.0", "html-entities": "^1.4.0", "is-ip": "^3.1.0", + "jszip": "^3.7.0", "katex": "^0.12.0", "linkifyjs": "^2.1.9", "lodash": "^4.17.20", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", - "matrix-widget-api": "^0.1.0-beta.14", + "matrix-widget-api": "^0.1.0-beta.16", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", "pako": "^2.0.3", "parse5": "^6.0.1", "png-chunks-extract": "^1.0.0", + "posthog-js": "1.12.2", "prop-types": "^15.7.2", "qrcode": "^1.4.4", - "qs": "^6.9.6", "re-resizable": "^6.9.0", - "react": "^16.14.0", - "react-beautiful-dnd": "^4.0.1", - "react-dom": "^16.14.0", + "react": "17.0.2", + "react-beautiful-dnd": "^13.1.0", + "react-blurhash": "^0.1.3", + "react-dom": "17.0.2", "react-focus-lock": "^2.5.0", "react-transition-group": "^4.4.1", "resize-observer-polyfill": "^1.5.1", "rfc4648": "^1.4.0", "sanitize-html": "^2.3.2", "tar-js": "^0.3.0", - "text-encoding-utf-8": "^1.0.2", "url": "^0.11.0", "what-input": "^5.2.10", "zxcvbn": "^4.4.2" @@ -107,25 +111,30 @@ "devDependencies": { "@babel/cli": "^7.12.10", "@babel/core": "^7.12.10", + "@babel/eslint-parser": "^7.12.10", + "@babel/eslint-plugin": "^7.12.10", "@babel/parser": "^7.12.11", "@babel/plugin-proposal-class-properties": "^7.12.1", "@babel/plugin-proposal-decorators": "^7.12.12", "@babel/plugin-proposal-export-default-from": "^7.12.1", "@babel/plugin-proposal-numeric-separator": "^7.12.7", "@babel/plugin-proposal-object-rest-spread": "^7.12.1", - "@babel/plugin-transform-flow-comments": "^7.12.1", "@babel/plugin-transform-runtime": "^7.12.10", "@babel/preset-env": "^7.12.11", - "@babel/preset-flow": "^7.12.1", "@babel/preset-react": "^7.12.10", "@babel/preset-typescript": "^7.12.7", "@babel/register": "^7.12.10", "@babel/traverse": "^7.12.12", "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz", "@peculiar/webcrypto": "^1.1.4", + "@sentry/types": "^6.10.0", "@sinonjs/fake-timers": "^7.0.2", "@types/classnames": "^2.2.11", + "@types/commonmark": "^0.27.4", "@types/counterpart": "^0.18.1", + "@types/css-font-loading-module": "^0.0.6", + "@types/diff-match-patch": "^1.0.32", + "@types/file-saver": "^2.0.3", "@types/flux": "^3.1.9", "@types/jest": "^26.0.20", "@types/linkifyjs": "^2.1.3", @@ -135,23 +144,23 @@ "@types/pako": "^1.0.1", "@types/parse5": "^6.0.0", "@types/qrcode": "^1.3.5", - "@types/react": "^16.9", - "@types/react-dom": "^16.9.10", + "@types/react": "17.0.14", + "@types/react-beautiful-dnd": "^13.0.0", + "@types/react-dom": "17.0.9", "@types/react-transition-group": "^4.4.0", "@types/sanitize-html": "^2.3.1", "@types/zxcvbn": "^4.4.0", - "@typescript-eslint/eslint-plugin": "^4.14.0", - "@typescript-eslint/parser": "^4.14.0", - "babel-eslint": "^10.1.0", + "@typescript-eslint/eslint-plugin": "^4.17.0", + "@typescript-eslint/parser": "^4.17.0", + "@wojtekmaj/enzyme-adapter-react-17": "^0.6.1", + "allchange": "^1.0.3", "babel-jest": "^26.6.3", "chokidar": "^3.5.1", "concurrently": "^5.3.0", "enzyme": "^3.11.0", - "enzyme-adapter-react-16": "^1.15.6", "eslint": "7.18.0", - "eslint-config-matrix-org": "^0.2.0", - "eslint-plugin-babel": "^5.3.1", - "eslint-plugin-flowtype": "^5.2.0", + "eslint-config-google": "^0.14.0", + "eslint-plugin-matrix-org": "github:matrix-org/eslint-plugin-matrix-org#2306b3d4da4eba908b256014b979f1d3d43d2945", "eslint-plugin-react": "^7.22.0", "eslint-plugin-react-hooks": "^4.2.0", "glob": "^7.1.6", @@ -159,24 +168,27 @@ "jest-canvas-mock": "^2.3.0", "jest-environment-jsdom-sixteen": "^1.0.3", "jest-fetch-mock": "^3.0.3", + "jest-raw-loader": "^1.0.1", "matrix-mock-request": "^1.2.3", - "matrix-react-test-utils": "^0.2.2", + "matrix-react-test-utils": "^0.2.3", "matrix-web-i18n": "github:matrix-org/matrix-web-i18n", - "react-test-renderer": "^16.14.0", + "raw-loader": "^4.0.2", + "react-test-renderer": "^17.0.2", "rimraf": "^3.0.2", + "rrweb-snapshot": "1.1.7", "stylelint": "^13.9.0", "stylelint-config-standard": "^20.0.0", "stylelint-scss": "^3.18.0", - "typescript": "^4.1.3", + "typescript": "4.3.5", "walk": "^2.3.14" }, "resolutions": { - "**/@types/react": "^16.14" + "@types/react": "17.0.14" }, "jest": { "testEnvironment": "./__test-utils__/environment.js", "testMatch": [ - "/test/**/*-test.[jt]s" + "/test/**/*-test.[jt]s?(x)" ], "setupFiles": [ "jest-canvas-mock" @@ -189,7 +201,10 @@ "\\$webapp/i18n/languages.json": "/__mocks__/languages.json", "decoderWorker\\.min\\.js": "/__mocks__/empty.js", "decoderWorker\\.min\\.wasm": "/__mocks__/empty.js", - "waveWorker\\.min\\.js": "/__mocks__/empty.js" + "waveWorker\\.min\\.js": "/__mocks__/empty.js", + "workers/(.+)\\.worker\\.ts": "/__mocks__/workerMock.js", + "^!!raw-loader!.*": "jest-raw-loader", + "RecorderWorklet": "/__mocks__/empty.js" }, "transformIgnorePatterns": [ "/node_modules/(?!matrix-js-sdk).+$" diff --git a/release_config.yaml b/release_config.yaml new file mode 100644 index 0000000000..12e857cbdd --- /dev/null +++ b/release_config.yaml @@ -0,0 +1,4 @@ +subprojects: + matrix-js-sdk: + includeByDefault: false + diff --git a/res/css/_animations.scss b/res/css/_animations.scss new file mode 100644 index 0000000000..26252fcaf6 --- /dev/null +++ b/res/css/_animations.scss @@ -0,0 +1,80 @@ +/* +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. +*/ + +/** + * React Transition Group animations are prefixed with 'mx_rtg--' so that we + * know they should not be used anywhere outside of React Transition Groups. +*/ + +.mx_rtg--fade-enter { + opacity: 0; +} +.mx_rtg--fade-enter-active { + opacity: 1; + transition: opacity 300ms ease; +} +.mx_rtg--fade-exit { + opacity: 1; +} +.mx_rtg--fade-exit-active { + opacity: 0; + transition: opacity 300ms ease; +} + +@keyframes mx--anim-pulse { + 0% { opacity: 1; } + 50% { opacity: 0.7; } + 100% { opacity: 1; } +} + +@keyframes mx_Dialog_lightbox_background_keyframes { + from { + opacity: 0; + } + to { + opacity: $lightbox-background-bg-opacity; + } +} + +@keyframes mx_ImageView_panel_keyframes { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@media (prefers-reduced-motion) { + @keyframes mx--anim-pulse { + // Override all keyframes in reduced-motion + } + + @keyframes mx_Dialog_lightbox_background_keyframes { + // Override all keyframes in reduced-motion + } + + @keyframes mx_ImageView_panel_keyframes { + // Override all keyframes in reduced-motion + } + + .mx_rtg--fade-enter-active { + transition: none; + } + .mx_rtg--fade-exit-active { + transition: none; + } +} diff --git a/res/css/_common.scss b/res/css/_common.scss index b128a82442..d7f8355d81 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -18,6 +18,7 @@ limitations under the License. @import "./_font-sizes.scss"; @import "./_font-weights.scss"; +@import "./_animations.scss"; $hover-transition: 0.08s cubic-bezier(.46, .03, .52, .96); // quadratic @@ -52,8 +53,8 @@ html { body { font-family: $font-family; font-size: $font-15px; - background-color: $primary-bg-color; - color: $primary-fg-color; + background-color: $background; + color: $primary-content; border: 0px; margin: 0px; @@ -88,7 +89,7 @@ b { } h2 { - color: $primary-fg-color; + color: $primary-content; font-weight: 400; font-size: $font-18px; margin-top: 16px; @@ -104,8 +105,8 @@ a:visited { input[type=text], input[type=search], input[type=password] { + font-family: inherit; padding: 9px; - font-family: $font-family; font-size: $font-14px; font-weight: 600; min-width: 0; @@ -141,13 +142,12 @@ textarea::placeholder { input[type=text], input[type=password], textarea { background-color: transparent; - color: $primary-fg-color; + color: $primary-content; } /* Required by Firefox */ textarea { - font-family: $font-family; - color: $primary-fg-color; + color: $primary-content; } input[type=text]:focus, input[type=password]:focus, textarea:focus { @@ -168,12 +168,12 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { // it has the appearance of a text box so the controls // appear to be part of the input -.mx_Dialog, .mx_MatrixChat { +.mx_Dialog, .mx_MatrixChat_wrapper { .mx_textinput > input[type=text], .mx_textinput > input[type=search] { border: none; flex: 1; - color: $primary-fg-color; + color: $primary-content; } :not(.mx_textinput):not(.mx_Field):not(.mx_no_textinput) > input[type=text], @@ -184,7 +184,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { background-color: transparent; color: $input-darker-fg-color; border-radius: 4px; - border: 1px solid rgba($primary-fg-color, .1); + border: 1px solid rgba($primary-content, .1); // these things should probably not be defined globally margin: 9px; } @@ -209,7 +209,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { :not(.mx_textinput):not(.mx_Field):not(.mx_no_textinput) > input[type=search], .mx_textinput { color: $input-darker-fg-color; - background-color: $primary-bg-color; + background-color: $background; border: none; } } @@ -271,7 +271,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { } .mx_Dialog { - background-color: $primary-bg-color; + background-color: $background; color: $light-fg-color; z-index: 4012; font-weight: 300; @@ -318,6 +318,8 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { .mx_Dialog_lightbox .mx_Dialog_background { opacity: $lightbox-background-bg-opacity; background-color: $lightbox-background-bg-color; + animation-name: mx_Dialog_lightbox_background_keyframes; + animation-duration: 300ms; } .mx_Dialog_lightbox .mx_Dialog { @@ -379,7 +381,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { .mx_Dialog_content { margin: 24px 0 68px; font-size: $font-14px; - color: $primary-fg-color; + color: $primary-content; word-wrap: break-word; } @@ -488,8 +490,8 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { border-radius: 3px; border: 1px solid $input-border-color; padding: 9px; - color: $primary-fg-color; - background-color: $primary-bg-color; + color: $primary-content; + background-color: $background; } .mx_textButton { diff --git a/res/css/_components.scss b/res/css/_components.scss index 418b8f51c9..1bf17ab265 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -1,8 +1,10 @@ // autogenerated by rethemendex.sh +@import "./_animations.scss"; @import "./_common.scss"; @import "./_font-sizes.scss"; @import "./_font-weights.scss"; @import "./structures/_AutoHideScrollbar.scss"; +@import "./structures/_BackdropPanel.scss"; @import "./structures/_CompatibilityPage.scss"; @import "./structures/_ContextualMenu.scss"; @import "./structures/_CreateRoom.scss"; @@ -27,8 +29,8 @@ @import "./structures/_RoomView.scss"; @import "./structures/_ScrollPanel.scss"; @import "./structures/_SearchBox.scss"; +@import "./structures/_SpaceHierarchy.scss"; @import "./structures/_SpacePanel.scss"; -@import "./structures/_SpaceRoomDirectory.scss"; @import "./structures/_SpaceRoomView.scss"; @import "./structures/_TabbedView.scss"; @import "./structures/_ToastContainer.scss"; @@ -37,6 +39,11 @@ @import "./structures/_ViewSource.scss"; @import "./structures/auth/_CompleteSecurity.scss"; @import "./structures/auth/_Login.scss"; +@import "./views/audio_messages/_AudioPlayer.scss"; +@import "./views/audio_messages/_PlayPauseButton.scss"; +@import "./views/audio_messages/_PlaybackContainer.scss"; +@import "./views/audio_messages/_SeekBar.scss"; +@import "./views/audio_messages/_Waveform.scss"; @import "./views/auth/_AuthBody.scss"; @import "./views/auth/_AuthButtons.scss"; @import "./views/auth/_AuthFooter.scss"; @@ -52,7 +59,6 @@ @import "./views/avatars/_BaseAvatar.scss"; @import "./views/avatars/_DecoratedRoomAvatar.scss"; @import "./views/avatars/_MemberStatusMessageAvatar.scss"; -@import "./views/avatars/_PulsedAvatar.scss"; @import "./views/avatars/_WidgetAvatar.scss"; @import "./views/beta/_BetaCard.scss"; @import "./views/context_menus/_CallContextMenu.scss"; @@ -63,7 +69,6 @@ @import "./views/dialogs/_AddExistingToSpaceDialog.scss"; @import "./views/dialogs/_AddressPickerDialog.scss"; @import "./views/dialogs/_Analytics.scss"; -@import "./views/dialogs/_BetaFeedbackDialog.scss"; @import "./views/dialogs/_BugReportDialog.scss"; @import "./views/dialogs/_ChangelogDialog.scss"; @import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss"; @@ -72,15 +77,23 @@ @import "./views/dialogs/_CreateCommunityPrototypeDialog.scss"; @import "./views/dialogs/_CreateGroupDialog.scss"; @import "./views/dialogs/_CreateRoomDialog.scss"; +@import "./views/dialogs/_CreateSpaceFromCommunityDialog.scss"; +@import "./views/dialogs/_CreateSubspaceDialog.scss"; @import "./views/dialogs/_DeactivateAccountDialog.scss"; @import "./views/dialogs/_DevtoolsDialog.scss"; @import "./views/dialogs/_EditCommunityPrototypeDialog.scss"; +@import "./views/dialogs/_ExportDialog.scss"; @import "./views/dialogs/_FeedbackDialog.scss"; +@import "./views/dialogs/_ForwardDialog.scss"; +@import "./views/dialogs/_GenericFeatureFeedbackDialog.scss"; @import "./views/dialogs/_GroupAddressPicker.scss"; @import "./views/dialogs/_HostSignupDialog.scss"; @import "./views/dialogs/_IncomingSasDialog.scss"; @import "./views/dialogs/_InviteDialog.scss"; +@import "./views/dialogs/_JoinRuleDropdown.scss"; @import "./views/dialogs/_KeyboardShortcutsDialog.scss"; +@import "./views/dialogs/_LeaveSpaceDialog.scss"; +@import "./views/dialogs/_ManageRestrictedJoinRuleDialog.scss"; @import "./views/dialogs/_MessageEditHistoryDialog.scss"; @import "./views/dialogs/_ModalWidgetDialog.scss"; @import "./views/dialogs/_NewSessionReviewDialog.scss"; @@ -115,14 +128,15 @@ @import "./views/elements/_AddressTile.scss"; @import "./views/elements/_DesktopBuildsNotice.scss"; @import "./views/elements/_DesktopCapturerSourcePicker.scss"; +@import "./views/elements/_DialPadBackspaceButton.scss"; @import "./views/elements/_DirectorySearchBox.scss"; @import "./views/elements/_Dropdown.scss"; @import "./views/elements/_EditableItemList.scss"; @import "./views/elements/_ErrorBoundary.scss"; @import "./views/elements/_EventListSummary.scss"; +@import "./views/elements/_EventTilePreview.scss"; @import "./views/elements/_FacePile.scss"; @import "./views/elements/_Field.scss"; -@import "./views/elements/_FormButton.scss"; @import "./views/elements/_ImageView.scss"; @import "./views/elements/_InfoTooltip.scss"; @import "./views/elements/_InlineSpinner.scss"; @@ -144,6 +158,7 @@ @import "./views/elements/_StyledCheckbox.scss"; @import "./views/elements/_StyledRadioButton.scss"; @import "./views/elements/_SyntaxHighlight.scss"; +@import "./views/elements/_TagComposer.scss"; @import "./views/elements/_TextWithTooltip.scss"; @import "./views/elements/_ToggleSwitch.scss"; @import "./views/elements/_Tooltip.scss"; @@ -153,18 +168,20 @@ @import "./views/groups/_GroupPublicityToggle.scss"; @import "./views/groups/_GroupRoomList.scss"; @import "./views/groups/_GroupUserSettings.scss"; +@import "./views/messages/_CallEvent.scss"; @import "./views/messages/_CreateEvent.scss"; @import "./views/messages/_DateSeparator.scss"; @import "./views/messages/_EventTileBubble.scss"; @import "./views/messages/_MEmoteBody.scss"; @import "./views/messages/_MFileBody.scss"; @import "./views/messages/_MImageBody.scss"; +@import "./views/messages/_MImageReplyBody.scss"; @import "./views/messages/_MJitsiWidgetEvent.scss"; @import "./views/messages/_MNoticeBody.scss"; @import "./views/messages/_MStickerBody.scss"; @import "./views/messages/_MTextBody.scss"; @import "./views/messages/_MVideoBody.scss"; -@import "./views/messages/_MVoiceMessageBody.scss"; +@import "./views/messages/_MediaBody.scss"; @import "./views/messages/_MessageActionBar.scss"; @import "./views/messages/_MessageTimestamp.scss"; @import "./views/messages/_MjolnirBody.scss"; @@ -192,10 +209,12 @@ @import "./views/rooms/_E2EIcon.scss"; @import "./views/rooms/_EditMessageComposer.scss"; @import "./views/rooms/_EntityTile.scss"; +@import "./views/rooms/_EventBubbleTile.scss"; @import "./views/rooms/_EventTile.scss"; @import "./views/rooms/_GroupLayout.scss"; @import "./views/rooms/_IRCLayout.scss"; @import "./views/rooms/_JumpToBottomButton.scss"; +@import "./views/rooms/_LinkPreviewGroup.scss"; @import "./views/rooms/_LinkPreviewWidget.scss"; @import "./views/rooms/_MemberInfo.scss"; @import "./views/rooms/_MemberList.scss"; @@ -206,6 +225,7 @@ @import "./views/rooms/_PinnedEventTile.scss"; @import "./views/rooms/_PresenceLabel.scss"; @import "./views/rooms/_ReplyPreview.scss"; +@import "./views/rooms/_ReplyTile.scss"; @import "./views/rooms/_RoomBreadcrumbs.scss"; @import "./views/rooms/_RoomHeader.scss"; @import "./views/rooms/_RoomList.scss"; @@ -225,6 +245,8 @@ @import "./views/settings/_E2eAdvancedPanel.scss"; @import "./views/settings/_EmailAddresses.scss"; @import "./views/settings/_IntegrationManager.scss"; +@import "./views/settings/_JoinRuleSettings.scss"; +@import "./views/settings/_LayoutSwitcher.scss"; @import "./views/settings/_Notifications.scss"; @import "./views/settings/_PhoneNumbers.scss"; @import "./views/settings/_ProfileSettings.scss"; @@ -251,14 +273,16 @@ @import "./views/spaces/_SpacePublicShare.scss"; @import "./views/terms/_InlineTermsAgreement.scss"; @import "./views/toasts/_AnalyticsToast.scss"; +@import "./views/toasts/_IncomingCallToast.scss"; @import "./views/toasts/_NonUrgentEchoFailureToast.scss"; @import "./views/verification/_VerificationShowSas.scss"; -@import "./views/voice_messages/_PlayPauseButton.scss"; -@import "./views/voice_messages/_PlaybackContainer.scss"; -@import "./views/voice_messages/_Waveform.scss"; +@import "./views/voip/CallView/_CallViewButtons.scss"; @import "./views/voip/_CallContainer.scss"; +@import "./views/voip/_CallPreview.scss"; @import "./views/voip/_CallView.scss"; @import "./views/voip/_CallViewForRoom.scss"; +@import "./views/voip/_CallViewHeader.scss"; +@import "./views/voip/_CallViewSidebar.scss"; @import "./views/voip/_DialPad.scss"; @import "./views/voip/_DialPadContextMenu.scss"; @import "./views/voip/_DialPadModal.scss"; diff --git a/res/css/structures/_BackdropPanel.scss b/res/css/structures/_BackdropPanel.scss new file mode 100644 index 0000000000..482507cb15 --- /dev/null +++ b/res/css/structures/_BackdropPanel.scss @@ -0,0 +1,37 @@ +/* +Copyright 2021 New Vector Ltd + +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_BackdropPanel { + position: absolute; + left: 0; + top: 0; + height: 100vh; + width: 100%; + overflow: hidden; + filter: blur(var(--lp-background-blur)); + // Force a new layer for the backdropPanel so it's better hardware supported + transform: translateZ(0); +} + +.mx_BackdropPanel--image { + position: absolute; + top: 0; + left: 0; + min-height: 100%; + z-index: 0; + pointer-events: none; + overflow: hidden; +} diff --git a/res/css/structures/_ContextualMenu.scss b/res/css/structures/_ContextualMenu.scss index d7f2cb76e8..9f2b9e24b8 100644 --- a/res/css/structures/_ContextualMenu.scss +++ b/res/css/structures/_ContextualMenu.scss @@ -34,7 +34,7 @@ limitations under the License. border-radius: 8px; box-shadow: 4px 4px 12px 0 $menu-box-shadow-color; background-color: $menu-bg-color; - color: $primary-fg-color; + color: $primary-content; position: absolute; font-size: $font-14px; z-index: 5001; diff --git a/res/css/structures/_CreateRoom.scss b/res/css/structures/_CreateRoom.scss index e859beb20e..3d23ccc4b2 100644 --- a/res/css/structures/_CreateRoom.scss +++ b/res/css/structures/_CreateRoom.scss @@ -18,7 +18,7 @@ limitations under the License. width: 960px; margin-left: auto; margin-right: auto; - color: $primary-fg-color; + color: $primary-content; } .mx_CreateRoom input, diff --git a/res/css/structures/_FilePanel.scss b/res/css/structures/_FilePanel.scss index 7b975110e1..c180a8a02d 100644 --- a/res/css/structures/_FilePanel.scss +++ b/res/css/structures/_FilePanel.scss @@ -45,9 +45,14 @@ limitations under the License. /* Overrides for the attachment body tiles */ -.mx_FilePanel .mx_EventTile { +.mx_FilePanel .mx_EventTile:not([data-layout=bubble]) { word-break: break-word; - margin-top: 32px; + margin-top: 10px; + padding-top: 0; + + .mx_EventTile_line { + padding-left: 0; + } } .mx_FilePanel .mx_EventTile .mx_MImageBody { @@ -118,10 +123,6 @@ limitations under the License. padding-left: 0px; } -.mx_FilePanel .mx_EventTile:hover .mx_EventTile_line { - background-color: $primary-bg-color; -} - .mx_FilePanel_empty::before { mask-image: url('$(res)/img/element-icons/room/files.svg'); } diff --git a/res/css/structures/_GroupFilterPanel.scss b/res/css/structures/_GroupFilterPanel.scss index 444435dd57..ceea20ed79 100644 --- a/res/css/structures/_GroupFilterPanel.scss +++ b/res/css/structures/_GroupFilterPanel.scss @@ -14,10 +14,27 @@ See the License for the specific language governing permissions and limitations under the License. */ +$groupFilterPanelWidth: 56px; // only applies in this file, used for calculations + +.mx_GroupFilterPanelContainer { + flex-grow: 0; + flex-shrink: 0; + width: $groupFilterPanelWidth; + height: 100%; + + // Create another flexbox so the GroupFilterPanel fills the container + display: flex; + flex-direction: column; + + // GroupFilterPanel handles its own CSS +} + .mx_GroupFilterPanel { - flex: 1; + z-index: 1; background-color: $groupFilterPanel-bg-color; + flex: 1; cursor: pointer; + position: relative; display: flex; flex-direction: column; @@ -56,12 +73,6 @@ limitations under the License. .mx_GroupFilterPanel .mx_TagTile { // opacity: 0.5; position: relative; - - .mx_BetaDot { - position: absolute; - right: -13px; - top: -11px; - } } .mx_GroupFilterPanel .mx_TagTile.mx_TagTile_prototype { @@ -75,13 +86,13 @@ limitations under the License. } .mx_GroupFilterPanel .mx_TagTile.mx_TagTile_selected_prototype { - background-color: $primary-bg-color; + background-color: $background; border-radius: 6px; } .mx_TagTile_selected_prototype { .mx_TagTile_homeIcon::before { - background-color: $primary-fg-color; // dark-on-light + background-color: $primary-content; // dark-on-light } } diff --git a/res/css/structures/_GroupView.scss b/res/css/structures/_GroupView.scss index 2350d9f28a..5e224b1f38 100644 --- a/res/css/structures/_GroupView.scss +++ b/res/css/structures/_GroupView.scss @@ -132,7 +132,7 @@ limitations under the License. width: 100%; height: 31px; overflow: hidden; - color: $primary-fg-color; + color: $primary-content; font-weight: bold; font-size: $font-22px; padding-left: 19px; @@ -323,7 +323,7 @@ limitations under the License. } .mx_GroupView_featuredThing .mx_BaseAvatar { - /* To prevent misalignment with mx_TintableSvg (in addButton) */ + /* To prevent misalignment with img (in addButton) */ vertical-align: initial; } @@ -368,6 +368,65 @@ limitations under the License. padding: 40px 20px; } +.mx_GroupView_spaceUpgradePrompt { + padding: 16px 50px; + background-color: $header-panel-bg-color; + border-radius: 8px; + max-width: 632px; + font-size: $font-15px; + line-height: $font-24px; + margin-top: 24px; + position: relative; + + > h2 { + font-size: inherit; + font-weight: $font-semi-bold; + } + + > p, h2 { + margin: 0; + } + + &::before { + content: ""; + position: absolute; + height: $font-24px; + width: 20px; + left: 18px; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + background-color: $secondary-content; + } + + .mx_AccessibleButton_kind_link { + padding: 0; + } + + .mx_GroupView_spaceUpgradePrompt_close { + width: 16px; + height: 16px; + border-radius: 8px; + background-color: $input-darker-bg-color; + position: absolute; + top: 16px; + right: 16px; + + &::before { + content: ""; + position: absolute; + width: inherit; + height: inherit; + mask-repeat: no-repeat; + mask-position: center; + mask-size: 8px; + mask-image: url('$(res)/img/image-view/close.svg'); + background-color: $secondary-content; + } + } +} + .mx_GroupView .mx_MemberInfo .mx_AutoHideScrollbar > :not(.mx_MemberInfo_avatar) { padding-left: 16px; padding-right: 16px; diff --git a/res/css/structures/_LeftPanel.scss b/res/css/structures/_LeftPanel.scss index c7dd678c07..5ddea244f3 100644 --- a/res/css/structures/_LeftPanel.scss +++ b/res/css/structures/_LeftPanel.scss @@ -14,31 +14,47 @@ See the License for the specific language governing permissions and limitations under the License. */ -$groupFilterPanelWidth: 56px; // only applies in this file, used for calculations $roomListCollapsedWidth: 68px; +.mx_MatrixChat--with-avatar { + .mx_LeftPanel, + .mx_LeftPanel .mx_LeftPanel_roomListContainer { + background-color: transparent; + } +} + +.mx_LeftPanel_wrapper { + display: flex; + max-width: 50%; + position: relative; + + // Contain the amount of layers rendered by constraining what actually needs re-layering via css + contain: layout paint; + + .mx_LeftPanel_wrapper--user { + background-color: $roomlist-bg-color; + display: flex; + overflow: hidden; + position: relative; + + &[data-collapsed] { + max-width: $roomListCollapsedWidth; + } + } +} + + + .mx_LeftPanel { background-color: $roomlist-bg-color; // TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel - min-width: 206px; - max-width: 50%; // Create a row-based flexbox for the GroupFilterPanel and the room list display: flex; contain: content; - - .mx_LeftPanel_GroupFilterPanelContainer { - flex-grow: 0; - flex-shrink: 0; - flex-basis: $groupFilterPanelWidth; - height: 100%; - - // Create another flexbox so the GroupFilterPanel fills the container - display: flex; - flex-direction: column; - - // GroupFilterPanel handles its own CSS - } + position: relative; + flex-grow: 1; + overflow: hidden; // Note: The 'room list' in this context is actually everything that isn't the tag // panel, such as the menu options, breadcrumbs, filtering, etc @@ -111,6 +127,29 @@ $roomListCollapsedWidth: 68px; } } + .mx_LeftPanel_dialPadButton { + width: 32px; + height: 32px; + border-radius: 8px; + background-color: $roomlist-button-bg-color; + position: relative; + margin-left: 8px; + + &::before { + content: ''; + position: absolute; + top: 8px; + left: 8px; + width: 16px; + height: 16px; + mask-image: url('$(res)/img/element-icons/call/dialpad.svg'); + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background: $secondary-content; + } + } + .mx_LeftPanel_exploreButton { width: 32px; height: 32px; @@ -130,7 +169,7 @@ $roomListCollapsedWidth: 68px; mask-position: center; mask-size: contain; mask-repeat: no-repeat; - background: $secondary-fg-color; + background: $secondary-content; } &.mx_LeftPanel_exploreButton_space::before { @@ -148,6 +187,8 @@ $roomListCollapsedWidth: 68px; } .mx_LeftPanel_roomListWrapper { + // Make the y-scrollbar more responsive + padding-right: 2px; overflow: hidden; margin-top: 10px; // so we're not up against the search/filter flex: 1 0 0; // needed in Safari to properly set flex-basis @@ -169,6 +210,7 @@ $roomListCollapsedWidth: 68px; // These styles override the defaults for the minimized (66px) layout &.mx_LeftPanel_minimized { + flex-grow: 0; min-width: unset; width: unset !important; @@ -185,6 +227,12 @@ $roomListCollapsedWidth: 68px; flex-direction: column; justify-content: center; + .mx_LeftPanel_dialPadButton { + margin-left: 0; + margin-top: 8px; + background-color: transparent; + } + .mx_LeftPanel_exploreButton { margin-left: 0; margin-top: 8px; diff --git a/res/css/structures/_LeftPanelWidget.scss b/res/css/structures/_LeftPanelWidget.scss index 6e2d99bb37..93c2898395 100644 --- a/res/css/structures/_LeftPanelWidget.scss +++ b/res/css/structures/_LeftPanelWidget.scss @@ -113,7 +113,7 @@ limitations under the License. &:hover .mx_LeftPanelWidget_resizerHandle { opacity: 0.8; - background-color: $primary-fg-color; + background-color: $primary-content; } .mx_LeftPanelWidget_maximizeButton { diff --git a/res/css/structures/_MainSplit.scss b/res/css/structures/_MainSplit.scss index 8199121420..407a1c270c 100644 --- a/res/css/structures/_MainSplit.scss +++ b/res/css/structures/_MainSplit.scss @@ -38,7 +38,7 @@ limitations under the License. width: 4px !important; border-radius: 4px !important; - background-color: $primary-fg-color; + background-color: $primary-content; opacity: 0.8; } } diff --git a/res/css/structures/_MatrixChat.scss b/res/css/structures/_MatrixChat.scss index a220c5d505..fdf5cb1a03 100644 --- a/res/css/structures/_MatrixChat.scss +++ b/res/css/structures/_MatrixChat.scss @@ -29,8 +29,6 @@ limitations under the License. .mx_MatrixChat_wrapper { display: flex; - flex-direction: column; - width: 100%; height: 100%; } @@ -42,13 +40,12 @@ limitations under the License. } .mx_MatrixChat { + position: relative; width: 100%; height: 100%; display: flex; - order: 2; - flex: 1; min-height: 0; } @@ -66,8 +63,8 @@ limitations under the License. } /* not the left panel, and not the resize handle, so the roomview/groupview/... */ -.mx_MatrixChat > :not(.mx_LeftPanel):not(.mx_SpacePanel):not(.mx_ResizeHandle) { - background-color: $primary-bg-color; +.mx_MatrixChat > :not(.mx_LeftPanel):not(.mx_SpacePanel):not(.mx_ResizeHandle):not(.mx_LeftPanel_wrapper) { + background-color: $background; flex: 1 1 0; min-width: 0; @@ -94,7 +91,7 @@ limitations under the License. content: ' '; - background-color: $primary-fg-color; + background-color: $primary-content; opacity: 0.8; } } diff --git a/res/css/structures/_NotificationPanel.scss b/res/css/structures/_NotificationPanel.scss index 1258ace069..68e1dd6a9a 100644 --- a/res/css/structures/_NotificationPanel.scss +++ b/res/css/structures/_NotificationPanel.scss @@ -49,7 +49,7 @@ limitations under the License. bottom: 0; left: 0; right: 0; - background-color: $tertiary-fg-color; + background-color: $tertiary-content; height: 1px; opacity: 0.4; content: ''; @@ -70,7 +70,7 @@ limitations under the License. } .mx_NotificationPanel .mx_EventTile_roomName a { - color: $primary-fg-color; + color: $primary-content; } .mx_NotificationPanel .mx_EventTile_avatar { @@ -79,13 +79,12 @@ limitations under the License. .mx_NotificationPanel .mx_EventTile .mx_SenderProfile, .mx_NotificationPanel .mx_EventTile .mx_MessageTimestamp { - color: $primary-fg-color; + color: $primary-content; font-size: $font-12px; display: inline; - padding-left: 0px; } -.mx_NotificationPanel .mx_EventTile_senderDetails { +.mx_NotificationPanel .mx_EventTile:not([data-layout=bubble]) .mx_EventTile_senderDetails { padding-left: 36px; // align with the room name position: relative; @@ -103,9 +102,10 @@ limitations under the License. visibility: visible; position: initial; display: inline; + padding-left: 5px; } -.mx_NotificationPanel .mx_EventTile_line { +.mx_NotificationPanel .mx_EventTile:not([data-layout=bubble]) .mx_EventTile_line { margin-right: 0px; padding-left: 36px; // align with the room name padding-top: 0px; @@ -118,7 +118,7 @@ limitations under the License. } .mx_NotificationPanel .mx_EventTile:hover .mx_EventTile_line { - background-color: $primary-bg-color; + background-color: $background; } .mx_NotificationPanel .mx_EventTile_content { diff --git a/res/css/structures/_RightPanel.scss b/res/css/structures/_RightPanel.scss index 52a2a68b6a..5316cba61d 100644 --- a/res/css/structures/_RightPanel.scss +++ b/res/css/structures/_RightPanel.scss @@ -43,7 +43,7 @@ limitations under the License. .mx_RightPanel_headerButtonGroup { height: 100%; display: flex; - background-color: $primary-bg-color; + background-color: $background; padding: 0 9px; align-items: center; } @@ -121,23 +121,51 @@ $pulse-color: $pinned-unread-color; box-shadow: 0 0 0 0 rgba($pulse-color, 1); animation: mx_RightPanel_indicator_pulse 2s infinite; animation-iteration-count: 1; + + &::after { + content: ""; + position: absolute; + width: inherit; + height: inherit; + top: 0; + left: 0; + transform: scale(1); + transform-origin: center center; + animation-name: mx_RightPanel_indicator_pulse_shadow; + animation-duration: inherit; + animation-iteration-count: inherit; + border-radius: 50%; + background: rgba($pulse-color, 1); + } } } @keyframes mx_RightPanel_indicator_pulse { 0% { transform: scale(0.95); - box-shadow: 0 0 0 0 rgba($pulse-color, 0.7); } 70% { transform: scale(1); - box-shadow: 0 0 0 10px rgba($pulse-color, 0); } 100% { transform: scale(0.95); - box-shadow: 0 0 0 0 rgba($pulse-color, 0); + } +} + +@keyframes mx_RightPanel_indicator_pulse_shadow { + 0% { + opacity: 0.7; + } + + 70% { + transform: scale(2.2); + opacity: 0; + } + + 100% { + opacity: 0; } } diff --git a/res/css/structures/_RoomDirectory.scss b/res/css/structures/_RoomDirectory.scss index ec07500af5..b6219da9e4 100644 --- a/res/css/structures/_RoomDirectory.scss +++ b/res/css/structures/_RoomDirectory.scss @@ -28,7 +28,7 @@ limitations under the License. .mx_RoomDirectory { margin-bottom: 12px; - color: $primary-fg-color; + color: $primary-content; word-break: break-word; display: flex; flex-direction: column; @@ -71,14 +71,14 @@ limitations under the License. font-weight: $font-semi-bold; font-size: $font-15px; line-height: $font-18px; - color: $primary-fg-color; + color: $primary-content; } > p { margin: 40px auto 60px; font-size: $font-14px; line-height: $font-20px; - color: $secondary-fg-color; + color: $secondary-content; max-width: 464px; // easier reading } @@ -97,7 +97,7 @@ limitations under the License. } .mx_RoomDirectory_table { - color: $primary-fg-color; + color: $primary-content; display: grid; font-size: $font-12px; grid-template-columns: max-content auto max-content max-content max-content; @@ -183,3 +183,40 @@ limitations under the License. padding: 0; } } + +@media screen and (max-width: 700px) { + .mx_RoomDirectory_roomMemberCount { + padding: 0px; + } + + .mx_AccessibleButton_kind_secondary { + padding: 0px !important; + } + + .mx_RoomDirectory_join { + margin-left: 0px; + } + + .mx_RoomDirectory_alias { + margin-top: 10px; + margin-bottom: 10px; + } + + .mx_RoomDirectory_roomDescription { + padding-bottom: 0px; + } + + .mx_RoomDirectory_name { + margin-bottom: 5px; + } + + .mx_RoomDirectory_roomAvatar { + margin-top: 10px; + } + + .mx_RoomDirectory_table { + grid-template-columns: auto; + row-gap: 14px; + margin-top: 5px; + } +} diff --git a/res/css/structures/_RoomSearch.scss b/res/css/structures/_RoomSearch.scss index 7fdafab5a6..bbd60a5ff3 100644 --- a/res/css/structures/_RoomSearch.scss +++ b/res/css/structures/_RoomSearch.scss @@ -33,14 +33,14 @@ limitations under the License. height: 16px; mask: url('$(res)/img/element-icons/roomlist/search.svg'); mask-repeat: no-repeat; - background-color: $secondary-fg-color; + background-color: $secondary-content; margin-left: 7px; } .mx_RoomSearch_input { border: none !important; // !important to override default app-wide styles flex: 1 !important; // !important to override default app-wide styles - color: $primary-fg-color !important; // !important to override default app-wide styles + color: $primary-content !important; // !important to override default app-wide styles padding: 0; height: 100%; width: 100%; @@ -48,12 +48,12 @@ limitations under the License. line-height: $font-16px; &:not(.mx_RoomSearch_inputExpanded)::placeholder { - color: $tertiary-fg-color !important; // !important to override default app-wide styles + color: $tertiary-content !important; // !important to override default app-wide styles } } &.mx_RoomSearch_hasQuery { - border-color: $secondary-fg-color; + border-color: $secondary-content; } &.mx_RoomSearch_focused { @@ -62,7 +62,7 @@ limitations under the License. } &.mx_RoomSearch_focused, &.mx_RoomSearch_hasQuery { - background-color: $roomlist-filter-active-bg-color; + background-color: $background; .mx_RoomSearch_clearButton { width: 16px; @@ -71,7 +71,7 @@ limitations under the License. mask-position: center; mask-size: contain; mask-repeat: no-repeat; - background-color: $secondary-fg-color; + background-color: $secondary-content; margin-right: 8px; } } diff --git a/res/css/structures/_RoomStatusBar.scss b/res/css/structures/_RoomStatusBar.scss index 8cc00aba0f..bdfbca1afa 100644 --- a/res/css/structures/_RoomStatusBar.scss +++ b/res/css/structures/_RoomStatusBar.scss @@ -27,7 +27,7 @@ limitations under the License. .mx_RoomStatusBar_typingIndicatorAvatars .mx_BaseAvatar_image { margin-right: -12px; - border: 1px solid $primary-bg-color; + border: 1px solid $background; } .mx_RoomStatusBar_typingIndicatorAvatars .mx_BaseAvatar_initial { @@ -39,7 +39,7 @@ limitations under the License. display: inline-block; color: #acacac; background-color: #ddd; - border: 1px solid $primary-bg-color; + border: 1px solid $background; border-radius: 40px; width: 24px; height: 24px; @@ -112,7 +112,7 @@ limitations under the License. .mx_AccessibleButton { padding: 5px 10px; - padding-left: 28px; // 16px for the icon, 2px margin to text, 10px regular padding + padding-left: 30px; // 18px for the icon, 2px margin to text, 10px regular padding display: inline-block; position: relative; @@ -128,13 +128,14 @@ limitations under the License. mask-repeat: no-repeat; mask-position: center; mask-size: contain; + width: 18px; + height: 18px; + top: 50%; // text sizes are dynamic + transform: translateY(-50%); } &.mx_RoomStatusBar_unsentCancelAllBtn::before { mask-image: url('$(res)/img/element-icons/trashcan.svg'); - width: 12px; - height: 16px; - top: calc(50% - 8px); // text sizes are dynamic } &.mx_RoomStatusBar_unsentResendAllBtn { @@ -142,9 +143,6 @@ limitations under the License. &::before { mask-image: url('$(res)/img/element-icons/retry.svg'); - width: 18px; - height: 18px; - top: calc(50% - 9px); // text sizes are dynamic } } } @@ -173,14 +171,14 @@ limitations under the License. } .mx_RoomStatusBar_connectionLostBar_desc { - color: $primary-fg-color; + color: $primary-content; font-size: $font-13px; opacity: 0.5; padding-bottom: 20px; } .mx_RoomStatusBar_resend_link { - color: $primary-fg-color !important; + color: $primary-content !important; text-decoration: underline !important; cursor: pointer; } @@ -189,7 +187,7 @@ limitations under the License. height: 50px; line-height: $font-50px; - color: $primary-fg-color; + color: $primary-content; opacity: 0.5; overflow-y: hidden; display: block; diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index 0efa2d01a1..fd9c4a14fc 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -14,10 +14,22 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_RoomView_wrapper { + display: flex; + flex-direction: column; + flex: 1; + position: relative; + justify-content: center; + // Contain the amount of layers rendered by constraining what actually needs re-layering via css + contain: strict; +} + .mx_RoomView { word-wrap: break-word; display: flex; flex-direction: column; + flex: 1; + position: relative; } @@ -40,7 +52,7 @@ limitations under the License. pointer-events: none; - background-color: $primary-bg-color; + background-color: $background; opacity: 0.95; position: absolute; @@ -57,14 +69,15 @@ limitations under the License. @keyframes mx_RoomView_fileDropTarget_image_animation { from { - width: 0px; + transform: scaleX(0); } to { - width: 32px; + transform: scaleX(1); } } .mx_RoomView_fileDropTarget_image { + width: 32px; animation: mx_RoomView_fileDropTarget_image_animation; animation-duration: 0.5s; margin-bottom: 16px; @@ -76,7 +89,6 @@ limitations under the License. margin: 0px auto; overflow: auto; - flex: 0 0 auto; } .mx_RoomView_auxPanel_fullHeight { @@ -86,7 +98,7 @@ limitations under the License. left: 0; right: 0; z-index: 3000; - background-color: $primary-bg-color; + background-color: $background; } .mx_RoomView_auxPanel_hiddenHighlights { @@ -152,7 +164,6 @@ limitations under the License. flex: 1; display: flex; flex-direction: column; - contain: content; } .mx_RoomView_statusArea { @@ -160,7 +171,7 @@ limitations under the License. flex: 0 0 auto; max-height: 0px; - background-color: $primary-bg-color; + background-color: $background; z-index: 1000; overflow: hidden; @@ -245,7 +256,7 @@ hr.mx_RoomView_myReadMarker { } .mx_RoomView_callStatusBar .mx_UploadBar_uploadProgressInner { - background-color: $primary-bg-color; + background-color: $background; } .mx_RoomView_callStatusBar .mx_UploadBar_uploadFilename { diff --git a/res/css/structures/_ScrollPanel.scss b/res/css/structures/_ScrollPanel.scss index 7b75c69e86..a668594bba 100644 --- a/res/css/structures/_ScrollPanel.scss +++ b/res/css/structures/_ScrollPanel.scss @@ -15,7 +15,6 @@ limitations under the License. */ .mx_ScrollPanel { - .mx_RoomView_MessageList { position: relative; display: flex; diff --git a/res/css/structures/_SpaceRoomDirectory.scss b/res/css/structures/_SpaceHierarchy.scss similarity index 80% rename from res/css/structures/_SpaceRoomDirectory.scss rename to res/css/structures/_SpaceHierarchy.scss index 7925686bf1..a5d589f9c2 100644 --- a/res/css/structures/_SpaceRoomDirectory.scss +++ b/res/css/structures/_SpaceHierarchy.scss @@ -14,21 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_SpaceRoomDirectory_dialogWrapper > .mx_Dialog { - max-width: 960px; - height: 100%; -} - -.mx_SpaceRoomDirectory { - height: 100%; - margin-bottom: 12px; - color: $primary-fg-color; - word-break: break-word; - display: flex; - flex-direction: column; -} - -.mx_SpaceRoomDirectory, .mx_SpaceRoomView_landing { .mx_Dialog_title { display: flex; @@ -52,7 +37,7 @@ limitations under the License. > div { font-weight: 400; - color: $secondary-fg-color; + color: $secondary-content; font-size: $font-15px; line-height: $font-24px; } @@ -61,29 +46,36 @@ limitations under the License. .mx_AccessibleButton_kind_link { padding: 0; + font-size: inherit; } .mx_SearchBox { margin: 24px 0 16px; } - .mx_SpaceRoomDirectory_noResults { + .mx_SpaceHierarchy_noResults { text-align: center; > div { font-size: $font-15px; line-height: $font-24px; - color: $secondary-fg-color; + color: $secondary-content; } } - .mx_SpaceRoomDirectory_listHeader { + .mx_SpaceHierarchy_listHeader { display: flex; min-height: 32px; align-items: center; font-size: $font-15px; line-height: $font-24px; - color: $primary-fg-color; + color: $primary-content; + margin-bottom: 12px; + + > h4 { + font-weight: $font-semi-bold; + margin: 0; + } .mx_AccessibleButton { padding: 4px 12px; @@ -104,7 +96,7 @@ limitations under the License. } } - .mx_SpaceRoomDirectory_error { + .mx_SpaceHierarchy_error { position: relative; font-weight: $font-semi-bold; color: $notice-primary-color; @@ -123,43 +115,44 @@ limitations under the License. background-image: url("$(res)/img/element-icons/warning-badge.svg"); } } -} -.mx_SpaceRoomDirectory_list { - margin-top: 16px; - padding-bottom: 40px; + .mx_SpaceHierarchy_list { + list-style: none; + padding: 0; + margin: 0; + } - .mx_SpaceRoomDirectory_roomCount { + .mx_SpaceHierarchy_roomCount { > h3 { display: inline; font-weight: $font-semi-bold; font-size: $font-18px; line-height: $font-22px; - color: $primary-fg-color; + color: $primary-content; } > span { margin-left: 8px; font-size: $font-15px; line-height: $font-24px; - color: $secondary-fg-color; + color: $secondary-content; } } - .mx_SpaceRoomDirectory_subspace { + .mx_SpaceHierarchy_subspace { .mx_BaseAvatar_image { border-radius: 8px; } } - .mx_SpaceRoomDirectory_subspace_toggle { + .mx_SpaceHierarchy_subspace_toggle { position: absolute; left: -1px; top: 10px; height: 16px; width: 16px; border-radius: 4px; - background-color: $primary-bg-color; + background-color: $background; &::before { content: ''; @@ -170,27 +163,26 @@ limitations under the License. width: 16px; mask-repeat: no-repeat; mask-position: center; - background-color: $tertiary-fg-color; + background-color: $tertiary-content; mask-size: 16px; transform: rotate(270deg); mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); } - &.mx_SpaceRoomDirectory_subspace_toggle_shown::before { + &.mx_SpaceHierarchy_subspace_toggle_shown::before { transform: rotate(0deg); } } - .mx_SpaceRoomDirectory_subspace_children { + .mx_SpaceHierarchy_subspace_children { position: relative; padding-left: 12px; } - .mx_SpaceRoomDirectory_roomTile { + .mx_SpaceHierarchy_roomTile { position: relative; padding: 8px 16px; border-radius: 8px; - min-height: 56px; box-sizing: border-box; display: grid; @@ -204,7 +196,7 @@ limitations under the License. grid-column: 1; } - .mx_SpaceRoomDirectory_roomTile_name { + .mx_SpaceHierarchy_roomTile_name { font-weight: $font-semi-bold; font-size: $font-15px; line-height: $font-18px; @@ -214,7 +206,7 @@ limitations under the License. .mx_InfoTooltip { display: inline; margin-left: 12px; - color: $tertiary-fg-color; + color: $tertiary-content; font-size: $font-12px; line-height: $font-15px; @@ -232,10 +224,10 @@ limitations under the License. } } - .mx_SpaceRoomDirectory_roomTile_info { + .mx_SpaceHierarchy_roomTile_info { font-size: $font-14px; line-height: $font-18px; - color: $secondary-fg-color; + color: $secondary-content; grid-row: 2; grid-column: 1/3; display: -webkit-box; @@ -244,7 +236,7 @@ limitations under the License. overflow: hidden; } - .mx_SpaceRoomDirectory_actions { + .mx_SpaceHierarchy_actions { text-align: right; margin-left: 20px; grid-column: 3; @@ -269,7 +261,7 @@ limitations under the License. } } - &:hover { + &:hover, &:focus-within { background-color: $groupFilterPanel-bg-color; .mx_AccessibleButton { @@ -278,8 +270,12 @@ limitations under the License. } } - .mx_SpaceRoomDirectory_roomTile, - .mx_SpaceRoomDirectory_subspace_children { + li.mx_SpaceHierarchy_roomTileWrapper { + list-style: none; + } + + .mx_SpaceHierarchy_roomTile, + .mx_SpaceHierarchy_subspace_children { &::before { content: ""; position: absolute; @@ -291,12 +287,12 @@ limitations under the License. } } - .mx_SpaceRoomDirectory_actions { - .mx_SpaceRoomDirectory_actionsText { + .mx_SpaceHierarchy_actions { + .mx_SpaceHierarchy_actionsText { font-weight: normal; font-size: $font-12px; line-height: $font-15px; - color: $secondary-fg-color; + color: $secondary-content; } } @@ -307,7 +303,7 @@ limitations under the License. margin: 20px 0; } - .mx_SpaceRoomDirectory_createRoom { + .mx_SpaceHierarchy_createRoom { display: block; margin: 16px auto 0; width: max-content; diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index c433ccf275..30d421a351 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -20,18 +20,20 @@ $gutterSize: 16px; $activeBorderTransparentGap: 1px; $activeBackgroundColor: $roomtile-selected-bg-color; -$activeBorderColor: $secondary-fg-color; +$activeBorderColor: $secondary-content; .mx_SpacePanel { - flex: 0 0 auto; background-color: $groupFilterPanel-bg-color; + flex: 0 0 auto; padding: 0; margin: 0; + position: relative; + // Fix for the blurred avatar-background + z-index: 1; // Create another flexbox so the Panel fills the container display: flex; flex-direction: column; - overflow-y: auto; .mx_SpacePanel_spaceTreeWrapper { flex: 1; @@ -69,6 +71,12 @@ $activeBorderColor: $secondary-fg-color; cursor: pointer; } + .mx_SpaceItem_dragging { + .mx_SpaceButton_toggleCollapse { + visibility: hidden; + } + } + .mx_SpaceTreeLevel { display: flex; flex-direction: column; @@ -95,6 +103,16 @@ $activeBorderColor: $secondary-fg-color; } } + .mx_SpaceItem_new { + position: relative; + + .mx_BetaDot { + position: absolute; + left: 33px; + top: -5px; + } + } + .mx_SpaceItem:not(.hasSubSpaces) > .mx_SpaceButton { margin-left: $gutterSize; min-width: 40px; @@ -106,6 +124,7 @@ $activeBorderColor: $secondary-fg-color; align-items: center; padding: 4px 4px 4px 0; width: 100%; + cursor: pointer; &.mx_SpaceButton_active { &:not(.mx_SpaceButton_narrow) .mx_SpaceButton_selectionWrapper { @@ -130,7 +149,6 @@ $activeBorderColor: $secondary-fg-color; &:not(.mx_SpaceButton_narrow) { .mx_SpaceButton_selectionWrapper { width: 100%; - padding-right: 16px; overflow: hidden; } } @@ -142,7 +160,6 @@ $activeBorderColor: $secondary-fg-color; display: block; text-overflow: ellipsis; overflow: hidden; - padding-right: 8px; font-size: $font-14px; line-height: $font-18px; } @@ -187,22 +204,17 @@ $activeBorderColor: $secondary-fg-color; } &.mx_SpaceButton_new .mx_SpaceButton_icon { - background-color: $accent-color; - transition: all .1s ease-in-out; // TODO transition + background-color: $roomlist-button-bg-color; &::before { - background-color: #ffffff; + background-color: $primary-content; mask-image: url('$(res)/img/element-icons/plus.svg'); transition: all .2s ease-in-out; // TODO transition } } - &.mx_SpaceButton_newCancel .mx_SpaceButton_icon { - background-color: $icon-button-color; - - &::before { - transform: rotate(45deg); - } + &.mx_SpaceButton_newCancel .mx_SpaceButton_icon::before { + transform: rotate(45deg); } .mx_BaseAvatar_image { @@ -216,8 +228,7 @@ $activeBorderColor: $secondary-fg-color; margin-top: auto; margin-bottom: auto; display: none; - position: absolute; - right: 4px; + position: relative; &::before { top: 2px; @@ -230,14 +241,12 @@ $activeBorderColor: $secondary-fg-color; mask-size: contain; mask-repeat: no-repeat; mask-image: url('$(res)/img/element-icons/context-menu.svg'); - background: $primary-fg-color; + background: $primary-content; } } } .mx_SpacePanel_badgeContainer { - position: absolute; - // Create a flexbox to make aligning dot badges easier display: flex; align-items: center; @@ -255,6 +264,7 @@ $activeBorderColor: $secondary-fg-color; &.collapsed { .mx_SpaceButton { .mx_SpacePanel_badgeContainer { + position: absolute; right: 0; top: 0; @@ -284,19 +294,12 @@ $activeBorderColor: $secondary-fg-color; } &:not(.collapsed) { - .mx_SpacePanel_badgeContainer { - position: absolute; - right: 4px; - } - .mx_SpaceButton:hover, .mx_SpaceButton:focus-within, .mx_SpaceButton_hasMenuOpen { - &:not(.mx_SpaceButton_home):not(.mx_SpaceButton_invite) { + &:not(.mx_SpaceButton_invite) { // Hide the badge container on hover because it'll be a menu button .mx_SpacePanel_badgeContainer { - width: 0; - height: 0; display: none; } @@ -363,6 +366,14 @@ $activeBorderColor: $secondary-fg-color; .mx_SpacePanel_iconExplore::before { mask-image: url('$(res)/img/element-icons/roomlist/browse.svg'); } + + .mx_SpacePanel_noIcon { + display: none; + + & + .mx_IconizedContextMenu_label { + padding-left: 5px !important; // override default iconized label style to align with header + } + } } diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss index 4bc4af467c..812b6dcea9 100644 --- a/res/css/structures/_SpaceRoomView.scss +++ b/res/css/structures/_SpaceRoomView.scss @@ -32,7 +32,7 @@ $SpaceRoomViewInnerWidth: 428px; } > span { - color: $secondary-fg-color; + color: $secondary-content; } &::before { @@ -45,7 +45,7 @@ $SpaceRoomViewInnerWidth: 428px; mask-position: center; mask-repeat: no-repeat; mask-size: 24px; - background-color: $tertiary-fg-color; + background-color: $tertiary-content; } &:hover { @@ -56,12 +56,15 @@ $SpaceRoomViewInnerWidth: 428px; } > span { - color: $primary-fg-color; + color: $primary-content; } } } .mx_SpaceRoomView { + overflow-y: auto; + flex: 1; + .mx_MainSplit > div:first-child { padding: 80px 60px; flex-grow: 1; @@ -72,13 +75,13 @@ $SpaceRoomViewInnerWidth: 428px; margin: 0; font-size: $font-24px; font-weight: $font-semi-bold; - color: $primary-fg-color; + color: $primary-content; width: max-content; } .mx_SpaceRoomView_description { font-size: $font-15px; - color: $secondary-fg-color; + color: $secondary-content; margin-top: 12px; margin-bottom: 24px; max-width: $SpaceRoomViewInnerWidth; @@ -154,7 +157,7 @@ $SpaceRoomViewInnerWidth: 428px; font-weight: $font-semi-bold; font-size: $font-14px; line-height: $font-24px; - color: $primary-fg-color; + color: $primary-content; margin-top: 24px; position: relative; padding-left: 24px; @@ -176,7 +179,19 @@ $SpaceRoomViewInnerWidth: 428px; mask-position: center; mask-size: contain; mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); - background-color: $secondary-fg-color; + background-color: $secondary-content; + } + } + + .mx_SpaceRoomView_preview_migratedCommunity { + margin-bottom: 16px; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid $input-border-color; + width: max-content; + + .mx_BaseAvatar { + margin-right: 4px; } } @@ -195,14 +210,15 @@ $SpaceRoomViewInnerWidth: 428px; .mx_SpaceRoomView_preview_inviter_mxid { line-height: $font-24px; - color: $secondary-fg-color; + color: $secondary-content; } } } - > .mx_BaseAvatar_image, - > .mx_BaseAvatar > .mx_BaseAvatar_image { - border-radius: 12px; + > .mx_RoomAvatar_isSpaceRoom { + &.mx_BaseAvatar_image, .mx_BaseAvatar_image { + border-radius: 12px; + } } h1.mx_SpaceRoomView_preview_name { @@ -212,7 +228,7 @@ $SpaceRoomViewInnerWidth: 428px; .mx_SpaceRoomView_preview_topic { font-size: $font-14px; line-height: $font-22px; - color: $secondary-fg-color; + color: $secondary-content; margin: 20px 0; max-height: 160px; overflow-y: auto; @@ -234,6 +250,10 @@ $SpaceRoomViewInnerWidth: 428px; } .mx_SpaceRoomView_landing { + display: flex; + flex-direction: column; + min-width: 0; + > .mx_BaseAvatar_image, > .mx_BaseAvatar > .mx_BaseAvatar_image { border-radius: 12px; @@ -242,7 +262,7 @@ $SpaceRoomViewInnerWidth: 428px; .mx_SpaceRoomView_landing_name { margin: 24px 0 16px; font-size: $font-15px; - color: $secondary-fg-color; + color: $secondary-content; > span { display: inline-block; @@ -315,7 +335,7 @@ $SpaceRoomViewInnerWidth: 428px; top: 0; height: 24px; width: 24px; - background: $tertiary-fg-color; + background: $tertiary-content; mask-position: center; mask-size: contain; mask-repeat: no-repeat; @@ -328,26 +348,21 @@ $SpaceRoomViewInnerWidth: 428px; font-size: $font-15px; margin-top: 12px; margin-bottom: 16px; - white-space: pre; - } - - > hr { - border: none; - height: 1px; - background-color: $groupFilterPanel-bg-color; + white-space: pre-wrap; + word-wrap: break-word; } .mx_SearchBox { margin: 0 0 20px; + flex: 0; } .mx_SpaceFeedbackPrompt { - margin-bottom: 16px; - - // hide the HR as we have our own - & + hr { - display: none; - } + padding: 7px; // 8px - 1px border + border: 1px solid rgba($primary-content, .1); + border-radius: 8px; + width: max-content; + margin: 0 0 -40px auto; // collapse its own height to not push other components down } } @@ -365,6 +380,45 @@ $SpaceRoomViewInnerWidth: 428px; } } + .mx_SpaceRoomView_betaWarning { + padding: 12px 12px 12px 54px; + position: relative; + font-size: $font-15px; + line-height: $font-24px; + width: 432px; + border-radius: 8px; + background-color: $info-plinth-bg-color; + color: $secondary-content; + box-sizing: border-box; + + > h3 { + font-weight: $font-semi-bold; + font-size: inherit; + line-height: inherit; + margin: 0; + } + + > p { + font-size: inherit; + line-height: inherit; + margin: 0; + } + + &::before { + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; + content: ''; + width: 20px; + height: 20px; + position: absolute; + top: 14px; + left: 14px; + background-color: $secondary-content; + } + } + .mx_SpaceRoomView_inviteTeammates { // XXX remove this when spaces leaves Beta .mx_SpaceRoomView_inviteTeammates_betaDisclaimer { @@ -384,7 +438,7 @@ $SpaceRoomViewInnerWidth: 428px; } .mx_SpaceRoomView_inviteTeammates_buttons { - color: $secondary-fg-color; + color: $secondary-content; margin-top: 28px; .mx_AccessibleButton { @@ -400,7 +454,7 @@ $SpaceRoomViewInnerWidth: 428px; width: 24px; top: 0; left: 0; - background-color: $secondary-fg-color; + background-color: $secondary-content; mask-repeat: no-repeat; mask-position: center; mask-size: contain; @@ -419,7 +473,7 @@ $SpaceRoomViewInnerWidth: 428px; } .mx_SpaceRoomView_info { - color: $secondary-fg-color; + color: $secondary-content; font-size: $font-15px; line-height: $font-24px; margin: 20px 0; @@ -438,7 +492,7 @@ $SpaceRoomViewInnerWidth: 428px; left: -2px; mask-position: center; mask-repeat: no-repeat; - background-color: $tertiary-fg-color; + background-color: $tertiary-content; } } @@ -464,66 +518,3 @@ $SpaceRoomViewInnerWidth: 428px; } } } - -.mx_SpaceFeedbackPrompt { - margin-top: 18px; - margin-bottom: 12px; - - > hr { - border: none; - border-top: 1px solid $input-border-color; - margin-bottom: 12px; - } - - > div { - display: flex; - flex-direction: row; - font-size: $font-15px; - line-height: $font-24px; - - > span { - color: $secondary-fg-color; - position: relative; - padding-left: 32px; - font-size: inherit; - line-height: inherit; - margin-right: auto; - - &::before { - content: ''; - position: absolute; - left: 0; - top: 2px; - height: 20px; - width: 20px; - background-color: $secondary-fg-color; - mask-repeat: no-repeat; - mask-size: contain; - mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); - mask-position: center; - } - } - - .mx_AccessibleButton_kind_link { - color: $accent-color; - position: relative; - padding: 0 0 0 24px; - margin-left: 8px; - font-size: inherit; - line-height: inherit; - - &::before { - content: ''; - position: absolute; - left: 0; - height: 16px; - width: 16px; - background-color: $accent-color; - mask-repeat: no-repeat; - mask-size: contain; - mask-image: url('$(res)/img/element-icons/chat-bubbles.svg'); - mask-position: center; - } - } - } -} diff --git a/res/css/structures/_TabbedView.scss b/res/css/structures/_TabbedView.scss index 39a8ebed32..e185197f25 100644 --- a/res/css/structures/_TabbedView.scss +++ b/res/css/structures/_TabbedView.scss @@ -1,6 +1,7 @@ /* Copyright 2017 Travis Ralston Copyright 2019 New Vector Ltd +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. @@ -20,7 +21,6 @@ limitations under the License. padding: 0 0 0 16px; display: flex; flex-direction: column; - position: absolute; top: 0; bottom: 0; left: 0; @@ -28,11 +28,93 @@ limitations under the License. margin-top: 8px; } +.mx_TabbedView_tabsOnLeft { + flex-direction: column; + position: absolute; + + .mx_TabbedView_tabLabels { + width: 170px; + max-width: 170px; + position: fixed; + } + + .mx_TabbedView_tabPanel { + margin-left: 240px; // 170px sidebar + 70px padding + flex-direction: column; + } + + .mx_TabbedView_tabLabel_active { + background-color: $tab-label-active-bg-color; + color: $tab-label-active-fg-color; + } + + .mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon::before { + background-color: $tab-label-active-icon-bg-color; + } + + .mx_TabbedView_maskedIcon { + width: 16px; + height: 16px; + margin-left: 8px; + margin-right: 16px; + } + + .mx_TabbedView_maskedIcon::before { + mask-size: 16px; + width: 16px; + height: 16px; + } +} + +.mx_TabbedView_tabsOnTop { + flex-direction: column; + + .mx_TabbedView_tabLabels { + display: flex; + margin-bottom: 8px; + } + + .mx_TabbedView_tabLabel { + padding-left: 0px; + padding-right: 52px; + + .mx_TabbedView_tabLabel_text { + font-size: 15px; + color: $tertiary-content; + } + } + + .mx_TabbedView_tabPanel { + flex-direction: row; + } + + .mx_TabbedView_tabLabel_active { + color: $accent-color; + .mx_TabbedView_tabLabel_text { + color: $accent-color; + } + } + + .mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon::before { + background-color: $accent-color; + } + + .mx_TabbedView_maskedIcon { + width: 22px; + height: 22px; + margin-left: 0px; + margin-right: 8px; + } + + .mx_TabbedView_maskedIcon::before { + mask-size: 22px; + width: inherit; + height: inherit; + } +} + .mx_TabbedView_tabLabels { - width: 170px; - max-width: 170px; color: $tab-label-fg-color; - position: fixed; } .mx_TabbedView_tabLabel { @@ -46,43 +128,25 @@ limitations under the License. position: relative; } -.mx_TabbedView_tabLabel_active { - background-color: $tab-label-active-bg-color; - color: $tab-label-active-fg-color; -} - .mx_TabbedView_maskedIcon { - margin-left: 8px; - margin-right: 16px; - width: 16px; - height: 16px; display: inline-block; } .mx_TabbedView_maskedIcon::before { display: inline-block; - background-color: $tab-label-icon-bg-color; + background-color: $icon-button-color; mask-repeat: no-repeat; - mask-size: 16px; - width: 16px; - height: 16px; mask-position: center; content: ''; } -.mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon::before { - background-color: $tab-label-active-icon-bg-color; -} - .mx_TabbedView_tabLabel_text { vertical-align: middle; } .mx_TabbedView_tabPanel { - margin-left: 240px; // 170px sidebar + 70px padding flex-grow: 1; display: flex; - flex-direction: column; min-height: 0; // firefox } diff --git a/res/css/structures/_ToastContainer.scss b/res/css/structures/_ToastContainer.scss index 09f834a6e3..55181a8b53 100644 --- a/res/css/structures/_ToastContainer.scss +++ b/res/css/structures/_ToastContainer.scss @@ -28,7 +28,7 @@ limitations under the License. margin: 0 4px; grid-row: 2 / 4; grid-column: 1; - background-color: $dark-panel-bg-color; + background-color: $system; box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.5); border-radius: 8px; } @@ -36,8 +36,8 @@ limitations under the License. .mx_Toast_toast { grid-row: 1 / 3; grid-column: 1; - color: $primary-fg-color; - background-color: $dark-panel-bg-color; + background-color: $system; + color: $primary-content; box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.5); border-radius: 8px; overflow: hidden; @@ -63,7 +63,7 @@ limitations under the License. &.mx_Toast_icon_verification::after { mask-image: url("$(res)/img/e2e/normal.svg"); - background-color: $primary-fg-color; + background-color: $primary-content; } &.mx_Toast_icon_verification_warning { @@ -71,7 +71,7 @@ limitations under the License. &::before { background-color: #ffffff; mask-image: url('$(res)/img/e2e/normal.svg'); - mask-size: 90%; + mask-size: 80%; } &::after { @@ -82,7 +82,7 @@ limitations under the License. &.mx_Toast_icon_secure_backup::after { mask-image: url('$(res)/img/feather-customised/secure-backup.svg'); - background-color: $primary-fg-color; + background-color: $primary-content; } .mx_Toast_title, .mx_Toast_body { @@ -122,7 +122,7 @@ limitations under the License. float: right; font-size: $font-12px; line-height: $font-22px; - color: $muted-fg-color; + color: $secondary-content; } } @@ -135,10 +135,14 @@ limitations under the License. float: right; display: flex; - .mx_FormButton { + .mx_AccessibleButton { min-width: 96px; box-sizing: border-box; } + + .mx_AccessibleButton + .mx_AccessibleButton { + margin-left: 5px; + } } .mx_Toast_description { @@ -159,7 +163,7 @@ limitations under the License. } .mx_Toast_detail { - color: $secondary-fg-color; + color: $secondary-content; } .mx_Toast_deviceID { diff --git a/res/css/structures/_UserMenu.scss b/res/css/structures/_UserMenu.scss index 17e6ad75df..c10e7f60df 100644 --- a/res/css/structures/_UserMenu.scss +++ b/res/css/structures/_UserMenu.scss @@ -35,7 +35,7 @@ limitations under the License. // we cheat opacity on the theme colour with an after selector here &::after { content: ''; - border-bottom: 1px solid $primary-fg-color; // XXX: Variable abuse + border-bottom: 1px solid $primary-content; opacity: 0.2; display: block; padding-top: 8px; @@ -58,7 +58,7 @@ limitations under the License. mask-position: center; mask-size: contain; mask-repeat: no-repeat; - background: $tertiary-fg-color; + background: $tertiary-content; mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); } } @@ -176,7 +176,7 @@ limitations under the License. width: 85%; opacity: 0.2; border: none; - border-bottom: 1px solid $primary-fg-color; // XXX: Variable abuse + border-bottom: 1px solid $primary-content; } &.mx_IconizedContextMenu { @@ -292,7 +292,7 @@ limitations under the License. mask-position: center; mask-size: contain; mask-repeat: no-repeat; - background: $primary-fg-color; + background: $primary-content; } } diff --git a/res/css/structures/_ViewSource.scss b/res/css/structures/_ViewSource.scss index 248eab5d88..e3d6135ef3 100644 --- a/res/css/structures/_ViewSource.scss +++ b/res/css/structures/_ViewSource.scss @@ -24,7 +24,7 @@ limitations under the License. .mx_ViewSource_heading { font-size: $font-17px; font-weight: 400; - color: $primary-fg-color; + color: $primary-content; margin-top: 0.7em; } diff --git a/res/css/structures/auth/_Login.scss b/res/css/structures/auth/_Login.scss index 9c98ca3a1c..c4aaaca1d0 100644 --- a/res/css/structures/auth/_Login.scss +++ b/res/css/structures/auth/_Login.scss @@ -96,3 +96,10 @@ div.mx_AccessibleButton_kind_link.mx_Login_forgot { cursor: not-allowed; } } +.mx_Login_spinner { + display: flex; + justify-content: center; + align-items: center; + align-content: center; + padding: 14px; +} diff --git a/res/css/views/audio_messages/_AudioPlayer.scss b/res/css/views/audio_messages/_AudioPlayer.scss new file mode 100644 index 0000000000..3c2551e36a --- /dev/null +++ b/res/css/views/audio_messages/_AudioPlayer.scss @@ -0,0 +1,67 @@ +/* +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. +*/ + +.mx_MediaBody.mx_AudioPlayer_container { + padding: 16px 12px 12px 12px; + + .mx_AudioPlayer_primaryContainer { + display: flex; + + .mx_PlayPauseButton { + margin-right: 8px; + } + + .mx_AudioPlayer_mediaInfo { + flex: 1; + overflow: hidden; // makes the ellipsis on the file name work + + & > * { + display: block; + } + + .mx_AudioPlayer_mediaName { + color: $primary-content; + font-size: $font-15px; + line-height: $font-15px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + padding-bottom: 4px; // mimics the line-height differences in the Figma + } + + .mx_AudioPlayer_byline { + font-size: $font-12px; + line-height: $font-12px; + } + } + } + + .mx_AudioPlayer_seek { + display: flex; + align-items: center; + + .mx_SeekBar { + flex: 1; + } + + .mx_Clock { + width: $font-42px; // we're not using a monospace font, so fake it + min-width: $font-42px; // for flexbox + padding-left: 4px; // isolate from seek bar + text-align: right; + } + } +} diff --git a/res/css/views/voice_messages/_PlayPauseButton.scss b/res/css/views/audio_messages/_PlayPauseButton.scss similarity index 91% rename from res/css/views/voice_messages/_PlayPauseButton.scss rename to res/css/views/audio_messages/_PlayPauseButton.scss index 6caedafa29..714da3e605 100644 --- a/res/css/views/voice_messages/_PlayPauseButton.scss +++ b/res/css/views/audio_messages/_PlayPauseButton.scss @@ -18,6 +18,8 @@ limitations under the License. position: relative; width: 32px; height: 32px; + min-width: 32px; // for when the button is used in a flexbox + min-height: 32px; // for when the button is used in a flexbox border-radius: 32px; background-color: $voice-playback-button-bg-color; diff --git a/res/css/views/voice_messages/_PlaybackContainer.scss b/res/css/views/audio_messages/_PlaybackContainer.scss similarity index 72% rename from res/css/views/voice_messages/_PlaybackContainer.scss rename to res/css/views/audio_messages/_PlaybackContainer.scss index 20def16d6a..3886e38583 100644 --- a/res/css/views/voice_messages/_PlaybackContainer.scss +++ b/res/css/views/audio_messages/_PlaybackContainer.scss @@ -18,29 +18,28 @@ limitations under the License. // are shared amongst multiple voice message components. // Container for live recording and playback controls -.mx_VoiceMessagePrimaryContainer { - // 7px top and bottom for visual design. 12px left & right, but the waveform (right) - // has a 1px padding on it that we want to account for. - padding: 7px 12px 7px 11px; - background-color: $voice-record-waveform-bg-color; - border-radius: 12px; +.mx_MediaBody.mx_VoiceMessagePrimaryContainer { + // The waveform (right) has a 1px padding on it that we want to account for, otherwise + // inherit from mx_MediaBody + padding-right: 11px; // Cheat at alignment a bit display: flex; align-items: center; - color: $voice-record-waveform-fg-color; - font-size: $font-14px; - line-height: $font-24px; + contain: content; .mx_Waveform { .mx_Waveform_bar { background-color: $voice-record-waveform-incomplete-fg-color; + height: 100%; + /* Variable set by a JS component */ + transform: scaleY(max(0.05, var(--barHeight))); &.mx_Waveform_bar_100pct { // Small animation to remove the mechanical feel of progress transition: background-color 250ms ease; - background-color: $voice-record-waveform-fg-color; + background-color: $secondary-content; } } } @@ -50,4 +49,8 @@ limitations under the License. padding-right: 6px; // with the fixed width this ends up as a visual 8px most of the time, as intended. padding-left: 8px; // isolate from recording circle / play control } + + &.mx_VoiceMessagePrimaryContainer_noWaveform { + max-width: 162px; // with all the padding this results in 185px wide + } } diff --git a/res/css/views/audio_messages/_SeekBar.scss b/res/css/views/audio_messages/_SeekBar.scss new file mode 100644 index 0000000000..03449d009b --- /dev/null +++ b/res/css/views/audio_messages/_SeekBar.scss @@ -0,0 +1,103 @@ +/* +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. +*/ + +// CSS inspiration from: +// * https://www.w3schools.com/howto/howto_js_rangeslider.asp +// * https://stackoverflow.com/a/28283806 +// * https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/ + +.mx_SeekBar { + // Dev note: we deliberately do not have the -ms-track (and friends) selectors because we don't + // need to support IE. + + appearance: none; // default style override + + width: 100%; + height: 1px; + background: $quaternary-content; + outline: none; // remove blue selection border + position: relative; // for before+after pseudo elements later on + + cursor: pointer; + + &::-webkit-slider-thumb { + appearance: none; // default style override + + // Dev note: This needs to be duplicated with the -moz-range-thumb selector + // because otherwise Edge (webkit) will fail to see the styles and just refuse + // to apply them. + width: 8px; + height: 8px; + border-radius: 8px; + background-color: $tertiary-content; + cursor: pointer; + } + + &::-moz-range-thumb { + width: 8px; + height: 8px; + border-radius: 8px; + background-color: $tertiary-content; + cursor: pointer; + + // Firefox adds a border on the thumb + border: none; + } + + // This is for webkit support, but we can't limit the functionality of it to just webkit + // browsers. Firefox responds to webkit-prefixed values now, which means we can't use media + // or support queries to selectively apply the rule. An upside is that this CSS doesn't work + // in firefox, so it's just wasted CPU/GPU time. + &::before { // ::before to ensure it ends up under the thumb + content: ''; + background-color: $tertiary-content; + + // Absolute positioning to ensure it overlaps with the existing bar + position: absolute; + top: 0; + left: 0; + + // Sizing to match the bar + width: 100%; + height: 1px; + + // And finally dynamic width without overly hurting the rendering engine. + transform-origin: 0 100%; + transform: scaleX(var(--fillTo)); + } + + // This is firefox's built-in support for the above, with 100% less hacks. + &::-moz-range-progress { + background-color: $tertiary-content; + height: 1px; + } + + &:disabled { + opacity: 0.5; + } + + // Increase clickable area for the slider (approximately same size as browser default) + // We do it this way to keep the same padding and margins of the element, avoiding margin math. + // Source: https://front-back.com/expand-clickable-areas-for-a-better-touch-experience/ + &::after { + content: ''; + position: absolute; + top: -6px; + bottom: -6px; + left: 0; + right: 0; + } +} diff --git a/res/css/views/voice_messages/_Waveform.scss b/res/css/views/audio_messages/_Waveform.scss similarity index 100% rename from res/css/views/voice_messages/_Waveform.scss rename to res/css/views/audio_messages/_Waveform.scss diff --git a/res/css/views/auth/_AuthButtons.scss b/res/css/views/auth/_AuthButtons.scss index 8deb0f80ac..3a2ad2adf8 100644 --- a/res/css/views/auth/_AuthButtons.scss +++ b/res/css/views/auth/_AuthButtons.scss @@ -39,7 +39,7 @@ limitations under the License. min-width: 80px; background-color: $accent-color; - color: $primary-bg-color; + color: $background; cursor: pointer; diff --git a/res/css/views/auth/_InteractiveAuthEntryComponents.scss b/res/css/views/auth/_InteractiveAuthEntryComponents.scss index ffaad3cd7a..ec07b765fd 100644 --- a/res/css/views/auth/_InteractiveAuthEntryComponents.scss +++ b/res/css/views/auth/_InteractiveAuthEntryComponents.scss @@ -85,7 +85,7 @@ limitations under the License. .mx_InteractiveAuthEntryComponents_termsPolicy { display: flex; flex-direction: row; - justify-content: start; + justify-content: flex-start; align-items: center; } diff --git a/res/css/views/avatars/_DecoratedRoomAvatar.scss b/res/css/views/avatars/_DecoratedRoomAvatar.scss index 257b512579..4922068462 100644 --- a/res/css/views/avatars/_DecoratedRoomAvatar.scss +++ b/res/css/views/avatars/_DecoratedRoomAvatar.scss @@ -47,7 +47,7 @@ limitations under the License. mask-position: center; mask-size: contain; mask-repeat: no-repeat; - background: $secondary-fg-color; + background: $secondary-content; mask-image: url('$(res)/img/globe.svg'); } diff --git a/res/css/views/beta/_BetaCard.scss b/res/css/views/beta/_BetaCard.scss index 3463a653fc..a6b61d3ead 100644 --- a/res/css/views/beta/_BetaCard.scss +++ b/res/css/views/beta/_BetaCard.scss @@ -19,49 +19,68 @@ limitations under the License. padding: 24px; background-color: $settings-profile-placeholder-bg-color; border-radius: 8px; - display: flex; box-sizing: border-box; - > div { - .mx_BetaCard_title { - font-weight: $font-semi-bold; - font-size: $font-18px; - line-height: $font-22px; - color: $primary-fg-color; - margin: 4px 0 14px; + .mx_BetaCard_columns { + display: flex; - .mx_BetaCard_betaPill { - margin-left: 12px; + > div { + .mx_BetaCard_title { + font-weight: $font-semi-bold; + font-size: $font-18px; + line-height: $font-22px; + color: $primary-content; + margin: 4px 0 14px; + + .mx_BetaCard_betaPill { + margin-left: 12px; + } + } + + .mx_BetaCard_caption { + font-size: $font-15px; + line-height: $font-20px; + color: $secondary-content; + margin-bottom: 20px; + } + + .mx_BetaCard_buttons .mx_AccessibleButton { + display: block; + margin: 12px 0; + padding: 7px 40px; + width: auto; + } + + .mx_BetaCard_disclaimer { + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-content; + margin-top: 20px; } } - .mx_BetaCard_caption { - font-size: $font-15px; - line-height: $font-20px; - color: $secondary-fg-color; - margin-bottom: 20px; - } - - .mx_AccessibleButton { - display: block; - margin: 12px 0; - padding: 7px 40px; - width: auto; - } - - .mx_BetaCard_disclaimer { - font-size: $font-12px; - line-height: $font-15px; - color: $secondary-fg-color; - margin-top: 20px; + > img { + margin: auto 0 auto 20px; + width: 300px; + object-fit: contain; + height: 100%; } } - > img { - margin: auto 0 auto 20px; - width: 300px; - object-fit: contain; - height: 100%; + .mx_BetaCard_relatedSettings { + .mx_SettingsFlag { + margin: 16px 0 0; + font-size: $font-15px; + line-height: $font-24px; + color: $primary-content; + + .mx_SettingsFlag_microcopy { + margin-top: 4px; + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-content; + } + } } } @@ -91,24 +110,53 @@ $dot-size: 12px; width: $dot-size; transform: scale(1); background: rgba($pulse-color, 1); - box-shadow: 0 0 0 0 rgba($pulse-color, 1); animation: mx_Beta_bluePulse 2s infinite; animation-iteration-count: 20; + position: relative; + pointer-events: none; + + &::after { + content: ""; + position: absolute; + width: inherit; + height: inherit; + top: 0; + left: 0; + transform: scale(1); + transform-origin: center center; + animation-name: mx_Beta_bluePulse_shadow; + animation-duration: inherit; + animation-iteration-count: inherit; + border-radius: 50%; + background: rgba($pulse-color, 1); + } } @keyframes mx_Beta_bluePulse { 0% { transform: scale(0.95); - box-shadow: 0 0 0 0 rgba($pulse-color, 0.7); } 70% { transform: scale(1); - box-shadow: 0 0 0 10px rgba($pulse-color, 0); } 100% { transform: scale(0.95); - box-shadow: 0 0 0 0 rgba($pulse-color, 0); + } +} + +@keyframes mx_Beta_bluePulse_shadow { + 0% { + opacity: 0.7; + } + + 70% { + transform: scale(2.2); + opacity: 0; + } + + 100% { + opacity: 0; } } diff --git a/res/css/views/context_menus/_IconizedContextMenu.scss b/res/css/views/context_menus/_IconizedContextMenu.scss index 204435995f..ca40f18cd4 100644 --- a/res/css/views/context_menus/_IconizedContextMenu.scss +++ b/res/css/views/context_menus/_IconizedContextMenu.scss @@ -36,7 +36,7 @@ limitations under the License. // // Therefore, we just hack in a line and border the thing ourselves &::before { - border-top: 1px solid $primary-fg-color; + border-top: 1px solid $primary-content; opacity: 0.1; content: ''; @@ -63,7 +63,7 @@ limitations under the License. padding-top: 12px; padding-bottom: 12px; text-decoration: none; - color: $primary-fg-color; + color: $primary-content; font-size: $font-15px; line-height: $font-24px; @@ -99,6 +99,10 @@ limitations under the License. .mx_IconizedContextMenu_icon + .mx_IconizedContextMenu_label { padding-left: 14px; } + + .mx_BetaCard_betaPill { + margin-left: 16px; + } } } @@ -115,7 +119,7 @@ limitations under the License. mask-position: center; mask-size: contain; mask-repeat: no-repeat; - background: $primary-fg-color; + background: $primary-content; } } @@ -145,12 +149,17 @@ limitations under the License. } } - .mx_IconizedContextMenu_checked { + .mx_IconizedContextMenu_checked, + .mx_IconizedContextMenu_unchecked { margin-left: 16px; margin-right: -5px; + } - &::before { - mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg'); - } + .mx_IconizedContextMenu_checked::before { + mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg'); + } + + .mx_IconizedContextMenu_unchecked::before { + content: unset; } } diff --git a/res/css/views/context_menus/_MessageContextMenu.scss b/res/css/views/context_menus/_MessageContextMenu.scss index 2ecb93e734..5af748e28d 100644 --- a/res/css/views/context_menus/_MessageContextMenu.scss +++ b/res/css/views/context_menus/_MessageContextMenu.scss @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2021 Michael Weimann Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,16 +16,69 @@ limitations under the License. */ .mx_MessageContextMenu { - padding: 6px; -} -.mx_MessageContextMenu_field { - display: block; - padding: 3px 6px 3px 6px; - cursor: pointer; - white-space: nowrap; -} + .mx_IconizedContextMenu_icon { + width: 16px; + height: 16px; + display: block; -.mx_MessageContextMenu_field.mx_MessageContextMenu_fieldSet { - font-weight: bold; + &::before { + content: ''; + width: 16px; + height: 16px; + display: block; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background: $primary-content; + } + } + + .mx_MessageContextMenu_iconCollapse::before { + mask-image: url('$(res)/img/element-icons/message/chevron-up.svg'); + } + + .mx_MessageContextMenu_iconReport::before { + mask-image: url('$(res)/img/element-icons/warning-badge.svg'); + } + + .mx_MessageContextMenu_iconLink::before { + mask-image: url('$(res)/img/element-icons/link.svg'); + } + + .mx_MessageContextMenu_iconPermalink::before { + mask-image: url('$(res)/img/element-icons/room/share.svg'); + } + + .mx_MessageContextMenu_iconUnhidePreview::before { + mask-image: url('$(res)/img/element-icons/settings/appearance.svg'); + } + + .mx_MessageContextMenu_iconForward::before { + mask-image: url('$(res)/img/element-icons/message/fwd.svg'); + } + + .mx_MessageContextMenu_iconRedact::before { + mask-image: url('$(res)/img/element-icons/trashcan.svg'); + } + + .mx_MessageContextMenu_iconResend::before { + mask-image: url('$(res)/img/element-icons/retry.svg'); + } + + .mx_MessageContextMenu_iconSource::before { + mask-image: url('$(res)/img/element-icons/room/format-bar/code.svg'); + } + + .mx_MessageContextMenu_iconQuote::before { + mask-image: url('$(res)/img/element-icons/room/format-bar/quote.svg'); + } + + .mx_MessageContextMenu_iconPin::before { + mask-image: url('$(res)/img/element-icons/room/pin-upright.svg'); + } + + .mx_MessageContextMenu_iconUnpin::before { + mask-image: url('$(res)/img/element-icons/room/pin.svg'); + } } diff --git a/res/css/views/context_menus/_StatusMessageContextMenu.scss b/res/css/views/context_menus/_StatusMessageContextMenu.scss index fceb7fba34..1a97fb56c7 100644 --- a/res/css/views/context_menus/_StatusMessageContextMenu.scss +++ b/res/css/views/context_menus/_StatusMessageContextMenu.scss @@ -27,7 +27,7 @@ input.mx_StatusMessageContextMenu_message { border-radius: 4px; border: 1px solid $input-border-color; padding: 6.5px 11px; - background-color: $primary-bg-color; + background-color: $background; font-weight: normal; margin: 0 0 10px; } diff --git a/res/css/views/context_menus/_TagTileContextMenu.scss b/res/css/views/context_menus/_TagTileContextMenu.scss index 8929c8906e..14f5ec817e 100644 --- a/res/css/views/context_menus/_TagTileContextMenu.scss +++ b/res/css/views/context_menus/_TagTileContextMenu.scss @@ -38,10 +38,23 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/view-community.svg'); } +.mx_TagTileContextMenu_moveUp::before { + transform: rotate(180deg); + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); +} + +.mx_TagTileContextMenu_moveDown::before { + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); +} + .mx_TagTileContextMenu_hideCommunity::before { mask-image: url('$(res)/img/element-icons/hide.svg'); } +.mx_TagTileContextMenu_createSpace::before { + mask-image: url('$(res)/img/element-icons/message/fwd.svg'); +} + .mx_TagTileContextMenu_separator { margin-top: 0; margin-bottom: 0; diff --git a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss index 2776c477fc..444b29c9bf 100644 --- a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss +++ b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss @@ -44,70 +44,17 @@ limitations under the License. > h3 { margin: 0; - color: $secondary-fg-color; + color: $secondary-content; font-size: $font-12px; font-weight: $font-semi-bold; line-height: $font-15px; } - .mx_AddExistingToSpace_entry { - display: flex; - margin-top: 12px; - - // we can't target .mx_BaseAvatar here as it'll break the decorated avatar styling - .mx_DecoratedRoomAvatar { - margin-right: 12px; - } - - .mx_AddExistingToSpace_entry_name { - font-size: $font-15px; - line-height: 30px; - flex-grow: 1; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - margin-right: 12px; - } - - .mx_Checkbox { - align-items: center; - } - } - } - - .mx_AddExistingToSpace_section_spaces { - .mx_BaseAvatar { - margin-right: 12px; - } - - .mx_BaseAvatar_image { - border-radius: 8px; - } - } - - .mx_AddExistingToSpace_section_experimental { - position: relative; - border-radius: 8px; - margin: 12px 0; - padding: 8px 8px 8px 42px; - background-color: $header-panel-bg-color; - - font-size: $font-12px; - line-height: $font-15px; - color: $secondary-fg-color; - - &::before { - content: ''; - position: absolute; - left: 10px; - top: calc(50% - 8px); // vertical centering - height: 16px; - width: 16px; - background-color: $secondary-fg-color; - mask-repeat: no-repeat; - mask-size: contain; - mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); - mask-position: center; + .mx_AccessibleButton_kind_link { + font-size: $font-12px; + line-height: $font-15px; + margin-top: 8px; + padding: 0; } } @@ -119,7 +66,7 @@ limitations under the License. flex-grow: 1; font-size: $font-12px; line-height: $font-15px; - color: $secondary-fg-color; + color: $secondary-content; .mx_ProgressBar { height: 8px; @@ -132,7 +79,7 @@ limitations under the License. margin-top: 8px; font-size: $font-15px; line-height: $font-24px; - color: $primary-fg-color; + color: $primary-content; } > * { @@ -158,7 +105,7 @@ limitations under the License. margin-top: 4px; font-size: $font-12px; line-height: $font-15px; - color: $primary-fg-color; + color: $primary-content; } } @@ -179,7 +126,7 @@ limitations under the License. &::before { content: ''; position: absolute; - background-color: $primary-fg-color; + background-color: $primary-content; mask-repeat: no-repeat; mask-position: center; mask-size: contain; @@ -198,84 +145,113 @@ limitations under the License. .mx_AddExistingToSpaceDialog { width: 480px; - color: $primary-fg-color; + color: $primary-content; display: flex; flex-direction: column; flex-wrap: nowrap; min-height: 0; height: 80vh; - .mx_Dialog_title { - display: flex; - - .mx_BaseAvatar_image { - border-radius: 8px; - margin: 0; - vertical-align: unset; - } - - .mx_BaseAvatar { - display: inline-flex; - margin: auto 16px auto 5px; - vertical-align: middle; - } - - > div { - > h1 { - font-weight: $font-semi-bold; - font-size: $font-18px; - line-height: $font-22px; - margin: 0; - } - - .mx_AddExistingToSpaceDialog_onlySpace { - color: $secondary-fg-color; - font-size: $font-15px; - line-height: $font-24px; - } - } - - .mx_Dropdown_input { - border: none; - - > .mx_Dropdown_option { - padding-left: 0; - flex: unset; - height: unset; - color: $secondary-fg-color; - font-size: $font-15px; - line-height: $font-24px; - - .mx_BaseAvatar { - display: none; - } - } - - .mx_Dropdown_menu { - .mx_AddExistingToSpaceDialog_dropdownOptionActive { - color: $accent-color; - padding-right: 32px; - position: relative; - - &::before { - content: ''; - width: 20px; - height: 20px; - top: 8px; - right: 0; - position: absolute; - mask-position: center; - mask-size: contain; - mask-repeat: no-repeat; - background-color: $accent-color; - mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg'); - } - } - } - } - } - .mx_AddExistingToSpace { display: contents; } } + +.mx_SubspaceSelector { + display: flex; + + .mx_BaseAvatar_image { + border-radius: 8px; + margin: 0; + vertical-align: unset; + } + + .mx_BaseAvatar { + display: inline-flex; + margin: auto 16px auto 5px; + vertical-align: middle; + } + + > div { + > h1 { + font-weight: $font-semi-bold; + font-size: $font-18px; + line-height: $font-22px; + margin: 0; + } + } + + .mx_Dropdown_input { + border: none; + + > .mx_Dropdown_option { + padding-left: 0; + flex: unset; + height: unset; + color: $secondary-content; + font-size: $font-15px; + line-height: $font-24px; + + .mx_BaseAvatar { + display: none; + } + } + + .mx_Dropdown_menu { + .mx_SubspaceSelector_dropdownOptionActive { + color: $accent-color; + padding-right: 32px; + position: relative; + + &::before { + content: ''; + width: 20px; + height: 20px; + top: 8px; + right: 0; + position: absolute; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background-color: $accent-color; + mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg'); + } + } + } + } + + .mx_SubspaceSelector_onlySpace { + color: $secondary-content; + font-size: $font-15px; + line-height: $font-24px; + } +} + +.mx_AddExistingToSpace_entry { + display: flex; + margin-top: 12px; + + .mx_DecoratedRoomAvatar, // we can't target .mx_BaseAvatar here as it'll break the decorated avatar styling + .mx_BaseAvatar.mx_RoomAvatar_isSpaceRoom { + margin-right: 12px; + } + + img.mx_RoomAvatar_isSpaceRoom, + .mx_RoomAvatar_isSpaceRoom img { + border-radius: 8px; + } + + .mx_AddExistingToSpace_entry_name { + font-size: $font-15px; + line-height: 30px; + flex-grow: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-right: 12px; + } + + .mx_Checkbox { + align-items: center; + } +} diff --git a/res/css/views/dialogs/_AddressPickerDialog.scss b/res/css/views/dialogs/_AddressPickerDialog.scss index 136e497994..a1147e6fbc 100644 --- a/res/css/views/dialogs/_AddressPickerDialog.scss +++ b/res/css/views/dialogs/_AddressPickerDialog.scss @@ -29,7 +29,6 @@ limitations under the License. .mx_AddressPickerDialog_input:focus { height: 26px; font-size: $font-14px; - font-family: $font-family; padding-left: 12px; padding-right: 12px; margin: 0 !important; diff --git a/res/css/views/dialogs/_CommunityPrototypeInviteDialog.scss b/res/css/views/dialogs/_CommunityPrototypeInviteDialog.scss index beae03f00f..5d6c817b14 100644 --- a/res/css/views/dialogs/_CommunityPrototypeInviteDialog.scss +++ b/res/css/views/dialogs/_CommunityPrototypeInviteDialog.scss @@ -65,7 +65,7 @@ limitations under the License. .mx_CommunityPrototypeInviteDialog_personName { font-weight: 600; font-size: $font-14px; - color: $primary-fg-color; + color: $primary-content; margin-left: 7px; } diff --git a/res/css/views/dialogs/_ConfirmUserActionDialog.scss b/res/css/views/dialogs/_ConfirmUserActionDialog.scss index 823f4d1e28..5ac0f07b14 100644 --- a/res/css/views/dialogs/_ConfirmUserActionDialog.scss +++ b/res/css/views/dialogs/_ConfirmUserActionDialog.scss @@ -34,10 +34,9 @@ limitations under the License. } .mx_ConfirmUserActionDialog_reasonField { - font-family: $font-family; font-size: $font-14px; - color: $primary-fg-color; - background-color: $primary-bg-color; + color: $primary-content; + background-color: $background; border-radius: 3px; border: solid 1px $input-border-color; diff --git a/res/css/views/dialogs/_CreateGroupDialog.scss b/res/css/views/dialogs/_CreateGroupDialog.scss index f7bfc61a98..ef9c2b73d4 100644 --- a/res/css/views/dialogs/_CreateGroupDialog.scss +++ b/res/css/views/dialogs/_CreateGroupDialog.scss @@ -29,8 +29,8 @@ limitations under the License. border-radius: 3px; border: 1px solid $input-border-color; padding: 9px; - color: $primary-fg-color; - background-color: $primary-bg-color; + color: $primary-content; + background-color: $background; } .mx_CreateGroupDialog_input_hasPrefixAndSuffix { diff --git a/res/css/views/dialogs/_CreateRoomDialog.scss b/res/css/views/dialogs/_CreateRoomDialog.scss index 2678f7b4ad..9cfa8ce25a 100644 --- a/res/css/views/dialogs/_CreateRoomDialog.scss +++ b/res/css/views/dialogs/_CreateRoomDialog.scss @@ -55,8 +55,8 @@ limitations under the License. border-radius: 3px; border: 1px solid $input-border-color; padding: 9px; - color: $primary-fg-color; - background-color: $primary-bg-color; + color: $primary-content; + background-color: $background; width: 100%; } @@ -65,7 +65,7 @@ limitations under the License. .mx_CreateRoomDialog_aliasContainer { display: flex; // put margin on container so it can collapse with siblings - margin: 10px 0; + margin: 24px 0 10px; .mx_RoomAliasField { margin: 0; @@ -101,10 +101,6 @@ limitations under the License. margin-left: 30px; } - .mx_CreateRoomDialog_topic { - margin-bottom: 36px; - } - .mx_Dialog_content > .mx_SettingsFlag { margin-top: 24px; } @@ -114,4 +110,3 @@ limitations under the License. font-size: $font-12px; } } - diff --git a/res/css/views/dialogs/_CreateSpaceFromCommunityDialog.scss b/res/css/views/dialogs/_CreateSpaceFromCommunityDialog.scss new file mode 100644 index 0000000000..6ff328f6ab --- /dev/null +++ b/res/css/views/dialogs/_CreateSpaceFromCommunityDialog.scss @@ -0,0 +1,187 @@ +/* +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. +*/ + +.mx_CreateSpaceFromCommunityDialog_wrapper { + .mx_Dialog { + display: flex; + flex-direction: column; + } +} + +.mx_CreateSpaceFromCommunityDialog { + width: 480px; + color: $primary-content; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + min-height: 0; + + .mx_CreateSpaceFromCommunityDialog_content { + > p { + font-size: $font-15px; + line-height: $font-24px; + + &:first-of-type { + margin-top: 0; + } + + &.mx_CreateSpaceFromCommunityDialog_flairNotice { + font-size: $font-12px; + line-height: $font-15px; + } + } + + .mx_SpaceBasicSettings { + > p { + font-size: $font-12px; + line-height: $font-15px; + margin: 16px 0; + } + + .mx_Field_textarea { + margin-bottom: 0; + } + } + + .mx_JoinRuleDropdown .mx_Dropdown_menu { + width: auto !important; // override fixed width + } + + .mx_CreateSpaceFromCommunityDialog_nonPublicSpacer { + height: 63px; // balance the height of the missing room alias field to prevent modal bouncing + } + } + + .mx_CreateSpaceFromCommunityDialog_footer { + display: flex; + margin-top: 20px; + + > span { + flex-grow: 1; + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-content; + + .mx_ProgressBar { + height: 8px; + width: 100%; + + @mixin ProgressBarBorderRadius 8px; + } + + .mx_CreateSpaceFromCommunityDialog_progressText { + margin-top: 8px; + font-size: $font-15px; + line-height: $font-24px; + color: $primary-content; + } + + > * { + vertical-align: middle; + } + } + + .mx_CreateSpaceFromCommunityDialog_error { + padding-left: 12px; + + > img { + align-self: center; + } + + .mx_CreateSpaceFromCommunityDialog_errorHeading { + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-18px; + color: $notice-primary-color; + } + + .mx_CreateSpaceFromCommunityDialog_errorCaption { + margin-top: 4px; + font-size: $font-12px; + line-height: $font-15px; + color: $primary-content; + } + } + + .mx_AccessibleButton { + display: inline-block; + align-self: center; + } + + .mx_AccessibleButton_kind_primary { + padding: 8px 36px; + margin-left: 24px; + } + + .mx_AccessibleButton_kind_primary_outline { + margin-left: auto; + } + + .mx_CreateSpaceFromCommunityDialog_retryButton { + margin-left: 12px; + padding-left: 24px; + position: relative; + + &::before { + content: ''; + position: absolute; + background-color: $primary-content; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/retry.svg'); + width: 18px; + height: 18px; + left: 0; + } + } + + .mx_AccessibleButton_kind_link { + padding: 0; + } + } +} + +.mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog { + .mx_InfoDialog { + max-width: 500px; + } + + .mx_AccessibleButton_kind_link { + padding: 0; + } + + .mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog_checkmark { + position: relative; + border-radius: 50%; + border: 3px solid $accent-color; + width: 68px; + height: 68px; + margin: 12px auto 32px; + + &::before { + width: inherit; + height: inherit; + content: ''; + position: absolute; + background-color: $accent-color; + mask-repeat: no-repeat; + mask-position: center; + mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg'); + mask-size: 48px; + } + } +} diff --git a/res/css/views/dialogs/_CreateSubspaceDialog.scss b/res/css/views/dialogs/_CreateSubspaceDialog.scss new file mode 100644 index 0000000000..1ed10df35c --- /dev/null +++ b/res/css/views/dialogs/_CreateSubspaceDialog.scss @@ -0,0 +1,81 @@ +/* +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. +*/ + +.mx_CreateSubspaceDialog_wrapper { + .mx_Dialog { + display: flex; + flex-direction: column; + } +} + +.mx_CreateSubspaceDialog { + width: 480px; + color: $primary-content; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + min-height: 0; + + .mx_CreateSubspaceDialog_content { + flex-grow: 1; + + .mx_CreateSubspaceDialog_betaNotice { + padding: 12px 16px; + border-radius: 8px; + background-color: $header-panel-bg-color; + + .mx_BetaCard_betaPill { + margin-right: 8px; + vertical-align: middle; + } + } + + .mx_JoinRuleDropdown + p { + color: $muted-fg-color; + font-size: $font-12px; + } + } + + .mx_CreateSubspaceDialog_footer { + display: flex; + margin-top: 20px; + + .mx_CreateSubspaceDialog_footer_prompt { + flex-grow: 1; + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-content; + + > * { + vertical-align: middle; + } + } + + .mx_AccessibleButton { + display: inline-block; + align-self: center; + } + + .mx_AccessibleButton_kind_primary { + margin-left: 16px; + padding: 8px 36px; + } + + .mx_AccessibleButton_kind_link { + padding: 0; + } + } +} diff --git a/res/css/views/dialogs/_DevtoolsDialog.scss b/res/css/views/dialogs/_DevtoolsDialog.scss index 8fee740016..4d35e8d569 100644 --- a/res/css/views/dialogs/_DevtoolsDialog.scss +++ b/res/css/views/dialogs/_DevtoolsDialog.scss @@ -55,22 +55,6 @@ limitations under the License. padding-right: 24px; } -.mx_DevTools_inputCell { - display: table-cell; - width: 240px; -} - -.mx_DevTools_inputCell input { - display: inline-block; - border: 0; - border-bottom: 1px solid $input-underline-color; - padding: 0; - width: 240px; - color: $input-fg-color; - font-family: $font-family; - font-size: $font-16px; -} - .mx_DevTools_textarea { font-size: $font-12px; max-width: 684px; @@ -139,7 +123,6 @@ limitations under the License. + .mx_DevTools_tgl-btn { padding: 2px; transition: all .2s ease; - font-family: sans-serif; perspective: 100px; &::after, &::before { diff --git a/res/css/views/dialogs/_ExportDialog.scss b/res/css/views/dialogs/_ExportDialog.scss new file mode 100644 index 0000000000..d578e72ead --- /dev/null +++ b/res/css/views/dialogs/_ExportDialog.scss @@ -0,0 +1,91 @@ +/* +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. +*/ + +.mx_ExportDialog { + .mx_ExportDialog_subheading { + font-size: $font-16px; + display: block; + font-family: $font-family; + font-weight: $font-semi-bold; + color: $primary-content; + margin-top: 18px; + margin-bottom: 12px; + } + + &.mx_ExportDialog_Exporting { + .mx_ExportDialog_options { + pointer-events: none; + } + + .mx_Field_select::before { + display: none; + } + + .mx_RadioButton input[type="radio"]:checked + div > div { + background: $greyed-fg-color; + } + + .mx_RadioButton input[type=radio]:checked + div { + border-color: unset; + } + + .mx_Field_valid.mx_Field label, + .mx_Field_valid.mx_Field:focus-within label { + color: unset; + } + + .mx_Field_valid.mx_Field, .mx_Field_valid.mx_Field:focus-within { + border-color: $input-border-color; + } + + .mx_Checkbox input[type="checkbox"]:checked + label > .mx_Checkbox_background { + background: $greyed-fg-color; + border-color: $greyed-fg-color; + } + } + + .mx_ExportDialog_progress { + .mx_Dialog_buttons { + margin-top: unset; + margin-left: 18px; + } + + .mx_Spinner { + width: unset; + height: unset; + flex: unset; + margin-right: 10px; + } + + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: center; + } + + .mx_RadioButton > .mx_RadioButton_content { + margin-top: 5px; + margin-bottom: 5px; + } + + .mx_Field { + width: 256px; + } + + .mx_Field_postfix { + padding: 9px 10px; + } +} diff --git a/res/css/views/dialogs/_FeedbackDialog.scss b/res/css/views/dialogs/_FeedbackDialog.scss index fd225dd882..74733f7220 100644 --- a/res/css/views/dialogs/_FeedbackDialog.scss +++ b/res/css/views/dialogs/_FeedbackDialog.scss @@ -33,7 +33,7 @@ limitations under the License. padding-left: 52px; > p { - color: $tertiary-fg-color; + color: $tertiary-content; } .mx_AccessibleButton_kind_link { diff --git a/res/css/views/dialogs/_ForwardDialog.scss b/res/css/views/dialogs/_ForwardDialog.scss new file mode 100644 index 0000000000..da8ce3de5b --- /dev/null +++ b/res/css/views/dialogs/_ForwardDialog.scss @@ -0,0 +1,163 @@ +/* +Copyright 2021 Robin Townsend + +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_ForwardDialog { + width: 520px; + color: $primary-content; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + min-height: 0; + height: 80vh; + + > h3 { + margin: 0 0 6px; + color: $secondary-content; + font-size: $font-12px; + font-weight: $font-semi-bold; + line-height: $font-15px; + } + + > .mx_ForwardDialog_preview { + max-height: 30%; + flex-shrink: 0; + overflow-y: auto; + + .mx_EventTile[data-layout=bubble] { + margin-top: 20px; + } + + div { + pointer-events: none; + } + + .mx_EventTile_msgOption { + display: none; + } + + // When forwarding messages from encrypted rooms, EventTile will complain + // that our preview is unencrypted, which doesn't actually matter + .mx_EventTile_e2eIcon_unencrypted { + display: none; + } + + // We also hide download links to not encourage users to try interacting + .mx_MFileBody_download { + display: none; + } + } + + > hr { + width: 100%; + border: none; + border-top: 1px solid $input-border-color; + margin: 12px 0; + } + + > .mx_ForwardList { + display: contents; + + .mx_SearchBox { + // To match the space around the title + margin: 0 0 15px 0; + flex-grow: 0; + } + + .mx_ForwardList_content { + flex-grow: 1; + } + + .mx_ForwardList_noResults { + display: block; + margin-top: 24px; + } + + .mx_ForwardList_results { + &:not(:first-child) { + margin-top: 24px; + } + + .mx_ForwardList_entry { + display: flex; + justify-content: space-between; + height: 32px; + padding: 6px; + border-radius: 8px; + + &:hover { + background-color: $groupFilterPanel-bg-color; + } + + .mx_ForwardList_roomButton { + display: flex; + margin-right: 12px; + min-width: 0; + + .mx_DecoratedRoomAvatar { + margin-right: 12px; + } + + .mx_ForwardList_entry_name { + font-size: $font-15px; + line-height: 30px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-right: 12px; + } + } + + .mx_ForwardList_sendButton { + position: relative; + + &:not(.mx_ForwardList_canSend) .mx_ForwardList_sendLabel { + // Hide the "Send" label while preserving button size + visibility: hidden; + } + + .mx_ForwardList_sendIcon, .mx_NotificationBadge { + position: absolute; + } + + .mx_NotificationBadge { + // Match the failed to send indicator's color with the disabled button + background-color: $button-danger-disabled-fg-color; + } + + &.mx_ForwardList_sending .mx_ForwardList_sendIcon { + background-color: $button-primary-bg-color; + mask-image: url('$(res)/img/element-icons/circle-sending.svg'); + mask-position: center; + mask-repeat: no-repeat; + mask-size: 14px; + width: 14px; + height: 14px; + } + + &.mx_ForwardList_sent .mx_ForwardList_sendIcon { + background-color: $button-primary-bg-color; + mask-image: url('$(res)/img/element-icons/circle-sent.svg'); + mask-position: center; + mask-repeat: no-repeat; + mask-size: 14px; + width: 14px; + height: 14px; + } + } + } + } + } +} diff --git a/res/css/views/dialogs/_BetaFeedbackDialog.scss b/res/css/views/dialogs/_GenericFeatureFeedbackDialog.scss similarity index 87% rename from res/css/views/dialogs/_BetaFeedbackDialog.scss rename to res/css/views/dialogs/_GenericFeatureFeedbackDialog.scss index 9f5f6b512e..ab7496249d 100644 --- a/res/css/views/dialogs/_BetaFeedbackDialog.scss +++ b/res/css/views/dialogs/_GenericFeatureFeedbackDialog.scss @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_BetaFeedbackDialog { - .mx_BetaFeedbackDialog_subheading { - color: $primary-fg-color; +.mx_GenericFeatureFeedbackDialog { + .mx_GenericFeatureFeedbackDialog_subheading { + color: $primary-content; font-size: $font-14px; line-height: $font-20px; margin-bottom: 24px; diff --git a/res/css/views/dialogs/_HostSignupDialog.scss b/res/css/views/dialogs/_HostSignupDialog.scss index ac4bc41951..d8a6652a39 100644 --- a/res/css/views/dialogs/_HostSignupDialog.scss +++ b/res/css/views/dialogs/_HostSignupDialog.scss @@ -70,11 +70,11 @@ limitations under the License. } .mx_HostSignupDialog_text_dark { - color: $primary-fg-color; + color: $primary-content; } .mx_HostSignupDialog_text_light { - color: $secondary-fg-color; + color: $secondary-content; } .mx_HostSignup_maximize_button { diff --git a/res/css/views/dialogs/_InviteDialog.scss b/res/css/views/dialogs/_InviteDialog.scss index d8ff56663a..a753115614 100644 --- a/res/css/views/dialogs/_InviteDialog.scss +++ b/res/css/views/dialogs/_InviteDialog.scss @@ -14,14 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_InviteDialog_transferWrapper .mx_Dialog { + padding-bottom: 16px; +} + .mx_InviteDialog_addressBar { display: flex; flex-direction: row; + // Right margin for the design. We could apply this to the whole dialog, but then the scrollbar + // for the user section gets weird. + margin: 8px 45px 0 0; .mx_InviteDialog_editor { flex: 1; width: 100%; // Needed to make the Field inside grow - background-color: $user-tile-hover-bg-color; + background-color: $header-panel-bg-color; border-radius: 4px; min-height: 25px; padding-left: 8px; @@ -49,7 +56,7 @@ limitations under the License. box-sizing: border-box; min-width: 40%; flex: 1 !important; - color: $primary-fg-color !important; + color: $primary-content !important; } } @@ -73,7 +80,7 @@ limitations under the License. } .mx_InviteDialog_section { - padding-bottom: 10px; + padding-bottom: 4px; h3 { font-size: $font-12px; @@ -82,6 +89,14 @@ limitations under the License. text-transform: uppercase; } + > p { + margin: 0; + } + + > span { + color: $primary-content; + } + .mx_InviteDialog_subname { margin-bottom: 10px; margin-top: -10px; // HACK: Positioning with margins is bad @@ -90,12 +105,69 @@ limitations under the License. } } +.mx_InviteDialog_section_hidden_suggestions_disclaimer { + padding: 8px 0 16px 0; + font-size: $font-14px; + + > span { + color: $primary-content; + font-weight: 600; + } + + > p { + margin: 0; + } +} + +.mx_InviteDialog_footer { + border-top: 1px solid $input-border-color; + + > h3 { + margin: 12px 0; + font-size: $font-12px; + color: $muted-fg-color; + font-weight: bold; + text-transform: uppercase; + } + + .mx_InviteDialog_footer_link { + display: flex; + justify-content: space-between; + border-radius: 4px; + border: solid 1px $light-fg-color; + padding: 8px; + + > a { + text-decoration: none; + flex-shrink: 1; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .mx_InviteDialog_footer_link_copy { + flex-shrink: 0; + cursor: pointer; + margin-left: 20px; + display: inherit; + + > div { + mask-image: url($copy-button-url); + background-color: $message-action-bar-fg-color; + margin-left: 5px; + width: 20px; + height: 20px; + background-repeat: no-repeat; + } + } +} + .mx_InviteDialog_roomTile { cursor: pointer; padding: 5px 10px; &:hover { - background-color: $user-tile-hover-bg-color; + background-color: $header-panel-bg-color; border-radius: 4px; } @@ -142,12 +214,13 @@ limitations under the License. .mx_InviteDialog_roomTile_nameStack { display: inline-block; + overflow: hidden; } .mx_InviteDialog_roomTile_name { font-weight: 600; font-size: $font-14px; - color: $primary-fg-color; + color: $primary-content; margin-left: 7px; } @@ -157,6 +230,13 @@ limitations under the License. margin-left: 7px; } + .mx_InviteDialog_roomTile_name, + .mx_InviteDialog_roomTile_userId { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .mx_InviteDialog_roomTile_time { text-align: right; font-size: $font-12px; @@ -210,26 +290,165 @@ limitations under the License. } } -.mx_InviteDialog { +.mx_InviteDialog_other { // Prevent the dialog from jumping around randomly when elements change. - height: 590px; + height: 600px; padding-left: 20px; // the design wants some padding on the left + + .mx_InviteDialog_userSections { + height: calc(100% - 115px); // mx_InviteDialog's height minus some for the upper and lower elements + } +} + +.mx_InviteDialog_content { + height: calc(100% - 36px); // full height minus the size of the header + overflow: hidden; +} + +.mx_InviteDialog_transfer { + width: 496px; + height: 466px; + flex-direction: column; + + .mx_InviteDialog_content { + flex-direction: column; + + .mx_TabbedView { + height: calc(100% - 60px); + } + overflow: visible; + } + + .mx_InviteDialog_addressBar { + margin-top: 8px; + } + + input[type="checkbox"] { + margin-right: 8px; + } } .mx_InviteDialog_userSections { - margin-top: 10px; + margin-top: 4px; overflow-y: auto; - padding-right: 45px; - height: 455px; // mx_InviteDialog's height minus some for the upper elements + padding: 0 45px 4px 0; } -// Right margin for the design. We could apply this to the whole dialog, but then the scrollbar -// for the user section gets weird. -.mx_InviteDialog_helpText, -.mx_InviteDialog_addressBar { - margin-right: 45px; +.mx_InviteDialog_hasFooter .mx_InviteDialog_userSections { + height: calc(100% - 175px); +} + +.mx_InviteDialog_helpText { + margin: 0; } .mx_InviteDialog_helpText .mx_AccessibleButton_kind_link { padding: 0; } + +.mx_InviteDialog_dialPad .mx_InviteDialog_dialPadField { + border-top: 0; + border-left: 0; + border-right: 0; + border-radius: 0; + margin-top: 0; + border-color: $quaternary-content; + + input { + font-size: 18px; + font-weight: 600; + padding-top: 0; + } +} + +.mx_InviteDialog_dialPad .mx_InviteDialog_dialPadField:focus-within { + border-color: $accent-color; +} + +.mx_InviteDialog_dialPadField .mx_Field_postfix { + /* Remove border separator between postfix and field content */ + border-left: none; +} + +.mx_InviteDialog_dialPad { + width: 224px; + margin-top: 16px; + margin-left: auto; + margin-right: auto; +} + +.mx_InviteDialog_dialPad .mx_DialPad { + row-gap: 16px; + column-gap: 48px; + + margin-left: auto; + margin-right: auto; +} + +.mx_InviteDialog_transferConsultConnect { + padding-top: 16px; + /* This wants a drop shadow the full width of the dialog, so relative-position it + * and make it wider, then compensate with padding + */ + position: relative; + width: 496px; + left: -24px; + padding-left: 24px; + padding-right: 24px; + border-top: 1px solid $quinary-content; + + display: flex; + flex-direction: row; + align-items: center; +} + +.mx_InviteDialog_transferConsultConnect_pushRight { + margin-left: auto; +} + +.mx_InviteDialog_userDirectoryIcon::before { + mask-image: url('$(res)/img/voip/tab-userdirectory.svg'); +} + +.mx_InviteDialog_dialPadIcon::before { + mask-image: url('$(res)/img/voip/tab-dialpad.svg'); +} + +.mx_InviteDialog_multiInviterError { + > h4 { + font-size: $font-15px; + line-height: $font-24px; + color: $secondary-content; + font-weight: normal; + } + + > div { + .mx_InviteDialog_multiInviterError_entry { + margin-bottom: 24px; + + .mx_InviteDialog_multiInviterError_entry_userProfile { + .mx_InviteDialog_multiInviterError_entry_name { + margin-left: 6px; + font-size: $font-15px; + line-height: $font-24px; + font-weight: $font-semi-bold; + color: $primary-content; + } + + .mx_InviteDialog_multiInviterError_entry_userId { + margin-left: 6px; + font-size: $font-12px; + line-height: $font-15px; + color: $tertiary-content; + } + } + + .mx_InviteDialog_multiInviterError_entry_error { + margin-left: 32px; + font-size: $font-15px; + line-height: $font-24px; + color: $notice-primary-color; + } + } + } +} diff --git a/res/css/views/dialogs/_JoinRuleDropdown.scss b/res/css/views/dialogs/_JoinRuleDropdown.scss new file mode 100644 index 0000000000..91691cf53b --- /dev/null +++ b/res/css/views/dialogs/_JoinRuleDropdown.scss @@ -0,0 +1,67 @@ +/* +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. +*/ + +.mx_JoinRuleDropdown { + margin-bottom: 8px; + font-weight: normal; + font-family: $font-family; + font-size: $font-14px; + color: $primary-content; + + .mx_Dropdown_input { + border: 1px solid $input-border-color; + } + + .mx_Dropdown_option { + font-size: $font-14px; + line-height: $font-32px; + height: 32px; + min-height: 32px; + + > div { + padding-left: 30px; + position: relative; + + &::before { + content: ""; + position: absolute; + height: 16px; + width: 16px; + left: 6px; + top: 8px; + mask-repeat: no-repeat; + mask-position: center; + background-color: $secondary-content; + } + } + } + + .mx_JoinRuleDropdown_invite::before { + mask-image: url('$(res)/img/element-icons/lock.svg'); + mask-size: contain; + } + + .mx_JoinRuleDropdown_public::before { + mask-image: url('$(res)/img/globe.svg'); + mask-size: 12px; + } + + .mx_JoinRuleDropdown_restricted::before { + mask-image: url('$(res)/img/element-icons/community-members.svg'); + mask-size: contain; + } +} + diff --git a/res/css/views/dialogs/_LeaveSpaceDialog.scss b/res/css/views/dialogs/_LeaveSpaceDialog.scss new file mode 100644 index 0000000000..0d85a87faf --- /dev/null +++ b/res/css/views/dialogs/_LeaveSpaceDialog.scss @@ -0,0 +1,96 @@ +/* +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. +*/ + +.mx_LeaveSpaceDialog_wrapper { + .mx_Dialog { + display: flex; + flex-direction: column; + padding: 24px 32px; + } +} + +.mx_LeaveSpaceDialog { + width: 440px; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + max-height: 520px; + + .mx_Dialog_content { + flex-grow: 1; + margin: 0; + overflow-y: auto; + + .mx_RadioButton + .mx_RadioButton { + margin-top: 16px; + } + + .mx_SearchBox { + // To match the space around the title + margin: 0 0 15px 0; + flex-grow: 0; + border-radius: 8px; + } + + .mx_LeaveSpaceDialog_noResults { + display: block; + margin-top: 24px; + } + + .mx_LeaveSpaceDialog_section { + margin: 16px 0; + } + + .mx_LeaveSpaceDialog_section_warning { + position: relative; + border-radius: 8px; + margin: 12px 0 0; + padding: 12px 8px 12px 42px; + background-color: $header-panel-bg-color; + + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-content; + + &::before { + content: ''; + position: absolute; + left: 10px; + top: calc(50% - 8px); // vertical centering + height: 16px; + width: 16px; + background-color: $secondary-content; + mask-repeat: no-repeat; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + mask-position: center; + } + } + + > p { + color: $primary-content; + } + } + + .mx_Dialog_buttons { + margin-top: 20px; + + .mx_Dialog_primary { + background-color: $notice-primary-color !important; // override default colour + border-color: $notice-primary-color; + } + } +} diff --git a/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.scss b/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.scss new file mode 100644 index 0000000000..9a05e7f20a --- /dev/null +++ b/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.scss @@ -0,0 +1,150 @@ +/* +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. +*/ + +.mx_ManageRestrictedJoinRuleDialog_wrapper { + .mx_Dialog { + display: flex; + flex-direction: column; + } +} + +.mx_ManageRestrictedJoinRuleDialog { + width: 480px; + color: $primary-content; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + min-height: 0; + height: 60vh; + + .mx_SearchBox { + // To match the space around the title + margin: 0 0 15px 0; + flex-grow: 0; + } + + .mx_ManageRestrictedJoinRuleDialog_content { + flex-grow: 1; + } + + .mx_ManageRestrictedJoinRuleDialog_noResults { + display: block; + margin-top: 24px; + } + + .mx_ManageRestrictedJoinRuleDialog_section { + &:not(:first-child) { + margin-top: 24px; + } + + > h3 { + margin: 0; + color: $secondary-content; + font-size: $font-12px; + font-weight: $font-semi-bold; + line-height: $font-15px; + } + + .mx_ManageRestrictedJoinRuleDialog_entry { + display: flex; + margin-top: 12px; + + > div { + flex-grow: 1; + } + + img.mx_RoomAvatar_isSpaceRoom, + .mx_RoomAvatar_isSpaceRoom img { + border-radius: 4px; + } + + .mx_ManageRestrictedJoinRuleDialog_entry_name { + margin: 0 8px; + font-size: $font-15px; + line-height: 30px; + flex-grow: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .mx_ManageRestrictedJoinRuleDialog_entry_description { + margin-top: 8px; + font-size: $font-12px; + line-height: $font-15px; + color: $tertiary-content; + } + + .mx_Checkbox { + align-items: center; + } + } + } + + .mx_ManageRestrictedJoinRuleDialog_section_spaces { + .mx_BaseAvatar { + margin-right: 12px; + } + + .mx_BaseAvatar_image { + border-radius: 8px; + } + } + + .mx_ManageRestrictedJoinRuleDialog_section_info { + position: relative; + border-radius: 8px; + margin: 12px 0; + padding: 8px 8px 8px 42px; + background-color: $header-panel-bg-color; + + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-content; + + &::before { + content: ''; + position: absolute; + left: 10px; + top: calc(50% - 8px); // vertical centering + height: 16px; + width: 16px; + background-color: $secondary-content; + mask-repeat: no-repeat; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + mask-position: center; + } + } + + .mx_ManageRestrictedJoinRuleDialog_footer { + margin-top: 20px; + + .mx_ManageRestrictedJoinRuleDialog_footer_buttons { + display: flex; + width: max-content; + margin-left: auto; + + .mx_AccessibleButton { + display: inline-block; + + & + .mx_AccessibleButton { + margin-left: 24px; + } + } + } + } +} diff --git a/res/css/views/dialogs/_MessageEditHistoryDialog.scss b/res/css/views/dialogs/_MessageEditHistoryDialog.scss index e9d777effd..4574344a28 100644 --- a/res/css/views/dialogs/_MessageEditHistoryDialog.scss +++ b/res/css/views/dialogs/_MessageEditHistoryDialog.scss @@ -37,7 +37,7 @@ limitations under the License. list-style-type: none; font-size: $font-14px; padding: 0; - color: $primary-fg-color; + color: $primary-content; span.mx_EditHistoryMessage_deletion, span.mx_EditHistoryMessage_insertion { padding: 0px 2px; diff --git a/res/css/views/dialogs/_RegistrationEmailPromptDialog.scss b/res/css/views/dialogs/_RegistrationEmailPromptDialog.scss index 31fc6d7a04..02c89e2e42 100644 --- a/res/css/views/dialogs/_RegistrationEmailPromptDialog.scss +++ b/res/css/views/dialogs/_RegistrationEmailPromptDialog.scss @@ -19,7 +19,7 @@ limitations under the License. .mx_Dialog_content { margin-bottom: 24px; - color: $tertiary-fg-color; + color: $tertiary-content; } .mx_Dialog_primary { diff --git a/res/css/views/dialogs/_RoomSettingsDialogBridges.scss b/res/css/views/dialogs/_RoomSettingsDialogBridges.scss index c97a3b69b7..f18b4917cf 100644 --- a/res/css/views/dialogs/_RoomSettingsDialogBridges.scss +++ b/res/css/views/dialogs/_RoomSettingsDialogBridges.scss @@ -72,7 +72,7 @@ limitations under the License. margin-top: 0px; margin-bottom: 0px; font-size: 16pt; - color: $primary-fg-color; + color: $primary-content; } > * { @@ -81,7 +81,7 @@ limitations under the License. } .workspace-channel-details { - color: $primary-fg-color; + color: $primary-content; font-weight: 600; .channel { diff --git a/res/css/views/dialogs/_ServerOfflineDialog.scss b/res/css/views/dialogs/_ServerOfflineDialog.scss index ae4b70beb3..7a1b0bbcab 100644 --- a/res/css/views/dialogs/_ServerOfflineDialog.scss +++ b/res/css/views/dialogs/_ServerOfflineDialog.scss @@ -17,10 +17,10 @@ limitations under the License. .mx_ServerOfflineDialog { .mx_ServerOfflineDialog_content { padding-right: 85px; - color: $primary-fg-color; + color: $primary-content; hr { - border-color: $primary-fg-color; + border-color: $primary-content; opacity: 0.1; border-bottom: none; } diff --git a/res/css/views/dialogs/_ServerPickerDialog.scss b/res/css/views/dialogs/_ServerPickerDialog.scss index b01b49d7af..9a05751f91 100644 --- a/res/css/views/dialogs/_ServerPickerDialog.scss +++ b/res/css/views/dialogs/_ServerPickerDialog.scss @@ -22,7 +22,7 @@ limitations under the License. margin-bottom: 0; > p { - color: $secondary-fg-color; + color: $secondary-content; font-size: $font-14px; margin: 16px 0; @@ -38,7 +38,7 @@ limitations under the License. > h4 { font-size: $font-15px; font-weight: $font-semi-bold; - color: $secondary-fg-color; + color: $secondary-content; margin-left: 8px; } diff --git a/res/css/views/dialogs/_SetEmailDialog.scss b/res/css/views/dialogs/_SetEmailDialog.scss index 37bee7a9ff..a39d51dfce 100644 --- a/res/css/views/dialogs/_SetEmailDialog.scss +++ b/res/css/views/dialogs/_SetEmailDialog.scss @@ -19,7 +19,7 @@ limitations under the License. border: 1px solid $input-border-color; padding: 9px; color: $input-fg-color; - background-color: $primary-bg-color; + background-color: $background; font-size: $font-15px; width: 100%; max-width: 280px; diff --git a/res/css/views/dialogs/_SettingsDialog.scss b/res/css/views/dialogs/_SettingsDialog.scss index 6c4ed35c5a..b3b6802c3d 100644 --- a/res/css/views/dialogs/_SettingsDialog.scss +++ b/res/css/views/dialogs/_SettingsDialog.scss @@ -15,7 +15,7 @@ limitations under the License. */ // Not actually a component but things shared by settings components -.mx_UserSettingsDialog, .mx_RoomSettingsDialog { +.mx_UserSettingsDialog, .mx_RoomSettingsDialog, .mx_SpaceSettingsDialog { width: 90vw; max-width: 1000px; // set the height too since tabbed view scrolls itself. diff --git a/res/css/views/dialogs/_ShareDialog.scss b/res/css/views/dialogs/_ShareDialog.scss index ce3fdd021f..4d5e1409db 100644 --- a/res/css/views/dialogs/_ShareDialog.scss +++ b/res/css/views/dialogs/_ShareDialog.scss @@ -50,7 +50,8 @@ limitations under the License. margin-left: 20px; display: inherit; } -.mx_ShareDialog_matrixto_copy > div { +.mx_ShareDialog_matrixto_copy::after { + content: ""; mask-image: url($copy-button-url); background-color: $message-action-bar-fg-color; margin-left: 5px; diff --git a/res/css/views/dialogs/_SpaceSettingsDialog.scss b/res/css/views/dialogs/_SpaceSettingsDialog.scss index 6e5fd9c8c8..e26e4f8b49 100644 --- a/res/css/views/dialogs/_SpaceSettingsDialog.scss +++ b/res/css/views/dialogs/_SpaceSettingsDialog.scss @@ -15,8 +15,7 @@ limitations under the License. */ .mx_SpaceSettingsDialog { - width: 480px; - color: $primary-fg-color; + color: $primary-content; .mx_SpaceSettings_errorText { font-weight: $font-semi-bold; @@ -32,8 +31,44 @@ limitations under the License. margin-left: 16px; } - .mx_AccessibleButton_kind_danger { - margin-top: 28px; + .mx_SettingsTab_section { + .mx_SettingsTab_section_caption { + margin-top: 12px; + margin-bottom: 20px; + } + + & + .mx_SettingsTab_subheading { + border-top: 1px solid $quinary-content; + margin-top: 0; + padding-top: 24px; + } + + .mx_RadioButton { + margin-top: 8px; + margin-bottom: 4px; + + .mx_RadioButton_content { + font-weight: $font-semi-bold; + line-height: $font-18px; + color: $primary-content; + } + + & + span { + font-size: $font-15px; + line-height: $font-18px; + color: $secondary-content; + margin-left: 26px; + } + } + + .mx_SettingsTab_showAdvanced { + margin: 16px 0; + padding: 0; + } + + .mx_SettingsFlag { + margin-top: 24px; + } } .mx_SpaceSettingsDialog_buttons { @@ -52,4 +87,14 @@ limitations under the License. .mx_AccessibleButton_hasKind { padding: 8px 22px; } + + .mx_TabbedView_tabLabel { + .mx_SpaceSettingsDialog_generalIcon::before { + mask-image: url('$(res)/img/element-icons/settings.svg'); + } + + .mx_SpaceSettingsDialog_visibilityIcon::before { + mask-image: url('$(res)/img/element-icons/eye.svg'); + } + } } diff --git a/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss b/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss index 30b79c1a9a..98edbf8ad8 100644 --- a/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss +++ b/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss @@ -28,6 +28,7 @@ limitations under the License. left: 0; top: 2px; // alignment background-image: url("$(res)/img/element-icons/warning-badge.svg"); + background-size: contain; } .mx_AccessSecretStorageDialog_reset_link { @@ -43,7 +44,7 @@ limitations under the License. margin-right: 8px; position: relative; top: 5px; - background-color: $primary-fg-color; + background-color: $primary-content; } .mx_AccessSecretStorageDialog_resetBadge::before { diff --git a/res/css/views/dialogs/security/_CreateSecretStorageDialog.scss b/res/css/views/dialogs/security/_CreateSecretStorageDialog.scss index d30803b1f0..b14206ff6d 100644 --- a/res/css/views/dialogs/security/_CreateSecretStorageDialog.scss +++ b/res/css/views/dialogs/security/_CreateSecretStorageDialog.scss @@ -56,7 +56,7 @@ limitations under the License. margin-right: 8px; position: relative; top: 5px; - background-color: $primary-fg-color; + background-color: $primary-content; } .mx_CreateSecretStorageDialog_secureBackupTitle::before { @@ -101,7 +101,7 @@ limitations under the License. margin-right: 8px; position: relative; top: 5px; - background-color: $primary-fg-color; + background-color: $primary-content; } .mx_CreateSecretStorageDialog_optionIcon_securePhrase { diff --git a/res/css/views/dialogs/security/_KeyBackupFailedDialog.scss b/res/css/views/dialogs/security/_KeyBackupFailedDialog.scss index 05ce158413..4a48012672 100644 --- a/res/css/views/dialogs/security/_KeyBackupFailedDialog.scss +++ b/res/css/views/dialogs/security/_KeyBackupFailedDialog.scss @@ -26,7 +26,7 @@ limitations under the License. &::before { mask: url("$(res)/img/e2e/lock-warning-filled.svg"); mask-repeat: no-repeat; - background-color: $primary-fg-color; + background-color: $primary-content; content: ""; position: absolute; top: -6px; diff --git a/res/css/views/directory/_NetworkDropdown.scss b/res/css/views/directory/_NetworkDropdown.scss index ae0927386a..93cecd8676 100644 --- a/res/css/views/directory/_NetworkDropdown.scss +++ b/res/css/views/directory/_NetworkDropdown.scss @@ -34,7 +34,7 @@ limitations under the License. box-sizing: border-box; border-radius: 4px; border: 1px solid $dialog-close-fg-color; - background-color: $primary-bg-color; + background-color: $background; max-height: calc(100vh - 20px); // allow 10px padding on both top and bottom overflow-y: auto; } @@ -153,7 +153,7 @@ limitations under the License. mask-position: center; mask-size: contain; mask-image: url('$(res)/img/feather-customised/chevron-down-thin.svg'); - background-color: $primary-fg-color; + background-color: $primary-content; } .mx_NetworkDropdown_handle_server { diff --git a/res/css/views/elements/_AccessibleButton.scss b/res/css/views/elements/_AccessibleButton.scss index 2997c83cfd..7bc47a3c98 100644 --- a/res/css/views/elements/_AccessibleButton.scss +++ b/res/css/views/elements/_AccessibleButton.scss @@ -72,7 +72,7 @@ limitations under the License. .mx_AccessibleButton_kind_danger_outline { color: $button-danger-bg-color; - background-color: $button-secondary-bg-color; + background-color: transparent; border: 1px solid $button-danger-bg-color; } diff --git a/res/css/views/elements/_AddressSelector.scss b/res/css/views/elements/_AddressSelector.scss index 087504390c..a7d463353b 100644 --- a/res/css/views/elements/_AddressSelector.scss +++ b/res/css/views/elements/_AddressSelector.scss @@ -16,7 +16,7 @@ limitations under the License. .mx_AddressSelector { position: absolute; - background-color: $primary-bg-color; + background-color: $background; width: 485px; max-height: 116px; overflow-y: auto; @@ -31,8 +31,8 @@ limitations under the License. } .mx_AddressSelector_addressListElement .mx_AddressTile { - background-color: $primary-bg-color; - border: solid 1px $primary-bg-color; + background-color: $background; + border: solid 1px $background; } .mx_AddressSelector_addressListElement.mx_AddressSelector_selected { diff --git a/res/css/views/elements/_AddressTile.scss b/res/css/views/elements/_AddressTile.scss index c42f52f8f4..90c40842f7 100644 --- a/res/css/views/elements/_AddressTile.scss +++ b/res/css/views/elements/_AddressTile.scss @@ -20,7 +20,7 @@ limitations under the License. background-color: rgba(74, 73, 74, 0.1); border: solid 1px $input-border-color; line-height: $font-26px; - color: $primary-fg-color; + color: $primary-content; font-size: $font-14px; font-weight: normal; margin-right: 4px; diff --git a/res/css/views/elements/_DesktopCapturerSourcePicker.scss b/res/css/views/elements/_DesktopCapturerSourcePicker.scss index 69dde5925e..b4a2c69b86 100644 --- a/res/css/views/elements/_DesktopCapturerSourcePicker.scss +++ b/res/css/views/elements/_DesktopCapturerSourcePicker.scss @@ -16,57 +16,41 @@ limitations under the License. .mx_desktopCapturerSourcePicker { overflow: hidden; -} -.mx_desktopCapturerSourcePicker_tabLabels { - display: flex; - padding: 0 0 8px 0; -} + .mx_desktopCapturerSourcePicker_tab { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: flex-start; + height: 500px; + overflow: overlay; -.mx_desktopCapturerSourcePicker_tabLabel, -.mx_desktopCapturerSourcePicker_tabLabel_selected { - width: 100%; - text-align: center; - border-radius: 8px; - padding: 8px 0; - font-size: $font-13px; -} + .mx_desktopCapturerSourcePicker_source { + width: 50%; + display: flex; + flex-direction: column; -.mx_desktopCapturerSourcePicker_tabLabel_selected { - background-color: $tab-label-active-bg-color; - color: $tab-label-active-fg-color; -} + .mx_desktopCapturerSourcePicker_source_thumbnail { + margin: 4px; + padding: 4px; + border-width: 2px; + border-radius: 8px; + border-style: solid; + border-color: transparent; -.mx_desktopCapturerSourcePicker_panel { - display: flex; - flex-wrap: wrap; - justify-content: center; - align-items: flex-start; - height: 500px; - overflow: overlay; -} + &.mx_desktopCapturerSourcePicker_source_thumbnail_selected, + &:hover, + &:focus { + border-color: $accent-color; + } + } -.mx_desktopCapturerSourcePicker_stream_button { - display: flex; - flex-direction: column; - margin: 8px; - border-radius: 4px; -} - -.mx_desktopCapturerSourcePicker_stream_button:hover, -.mx_desktopCapturerSourcePicker_stream_button:focus { - background: $roomtile-selected-bg-color; -} - -.mx_desktopCapturerSourcePicker_stream_thumbnail { - margin: 4px; - width: 312px; -} - -.mx_desktopCapturerSourcePicker_stream_name { - margin: 0 4px; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - width: 312px; + .mx_desktopCapturerSourcePicker_source_name { + margin: 0 4px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + } + } } diff --git a/res/css/views/elements/_DialPadBackspaceButton.scss b/res/css/views/elements/_DialPadBackspaceButton.scss new file mode 100644 index 0000000000..40e4af7025 --- /dev/null +++ b/res/css/views/elements/_DialPadBackspaceButton.scss @@ -0,0 +1,40 @@ +/* +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. +*/ + +.mx_DialPadBackspaceButton { + position: relative; + height: 28px; + width: 28px; + + &::before { + /* force this element to appear on the DOM */ + content: ""; + + background-color: #8D97A5; + width: inherit; + height: inherit; + top: 0px; + left: 0px; + position: absolute; + display: inline-block; + vertical-align: middle; + + mask-image: url('$(res)/img/element-icons/call/delete.svg'); + mask-position: 8px; + mask-size: 20px; + mask-repeat: no-repeat; + } +} diff --git a/res/css/views/elements/_Dropdown.scss b/res/css/views/elements/_Dropdown.scss index 2a2508c17c..1acac70e42 100644 --- a/res/css/views/elements/_Dropdown.scss +++ b/res/css/views/elements/_Dropdown.scss @@ -16,7 +16,7 @@ limitations under the License. .mx_Dropdown { position: relative; - color: $primary-fg-color; + color: $primary-content; } .mx_Dropdown_disabled { @@ -27,7 +27,7 @@ limitations under the License. display: flex; align-items: center; position: relative; - border-radius: 3px; + border-radius: 4px; border: 1px solid $strong-input-border-color; font-size: $font-12px; user-select: none; @@ -52,7 +52,7 @@ limitations under the License. padding-right: 9px; mask: url('$(res)/img/feather-customised/dropdown-arrow.svg'); mask-repeat: no-repeat; - background: $primary-fg-color; + background: $primary-content; } .mx_Dropdown_option { @@ -109,9 +109,9 @@ input.mx_Dropdown_option:focus { z-index: 2; margin: 0; padding: 0px; - border-radius: 3px; + border-radius: 4px; border: 1px solid $input-focused-border-color; - background-color: $primary-bg-color; + background-color: $background; max-height: 200px; overflow-y: auto; } diff --git a/res/css/views/elements/_EventTilePreview.scss b/res/css/views/elements/_EventTilePreview.scss new file mode 100644 index 0000000000..6bb726168f --- /dev/null +++ b/res/css/views/elements/_EventTilePreview.scss @@ -0,0 +1,22 @@ +/* +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. +*/ + +.mx_EventTilePreview_loader { + &.mx_IRCLayout, + &.mx_GroupLayout { + padding: 9px 0; + } +} diff --git a/res/css/views/elements/_FacePile.scss b/res/css/views/elements/_FacePile.scss index c691baffb5..875e0e34d5 100644 --- a/res/css/views/elements/_FacePile.scss +++ b/res/css/views/elements/_FacePile.scss @@ -25,7 +25,7 @@ limitations under the License. } .mx_BaseAvatar_image { - border: 1px solid $primary-bg-color; + border: 1px solid $background; } .mx_BaseAvatar_initial { @@ -47,7 +47,7 @@ limitations under the License. left: 0; height: inherit; width: inherit; - background: $tertiary-fg-color; + background: $tertiary-content; mask-position: center; mask-size: 20px; mask-repeat: no-repeat; @@ -60,6 +60,6 @@ limitations under the License. margin-left: 12px; font-size: $font-14px; line-height: $font-24px; - color: $tertiary-fg-color; + color: $tertiary-content; } } diff --git a/res/css/views/elements/_Field.scss b/res/css/views/elements/_Field.scss index f67da6477b..71d37a015d 100644 --- a/res/css/views/elements/_Field.scss +++ b/res/css/views/elements/_Field.scss @@ -38,16 +38,16 @@ limitations under the License. .mx_Field input, .mx_Field select, .mx_Field textarea { + font-family: inherit; font-weight: normal; - font-family: $font-family; font-size: $font-14px; border: none; // Even without a border here, we still need this avoid overlapping the rounded // corners on the field above. border-radius: 4px; padding: 8px 9px; - color: $primary-fg-color; - background-color: $primary-bg-color; + color: $primary-content; + background-color: $background; flex: 1; min-width: 0; } @@ -67,7 +67,7 @@ limitations under the License. height: 6px; mask: url('$(res)/img/feather-customised/dropdown-arrow.svg'); mask-repeat: no-repeat; - background-color: $primary-fg-color; + background-color: $primary-content; z-index: 1; pointer-events: none; } @@ -98,14 +98,14 @@ limitations under the License. transition: font-size 0.25s ease-out 0.1s, color 0.25s ease-out 0.1s, - top 0.25s ease-out 0.1s, + transform 0.25s ease-out 0.1s, background-color 0.25s ease-out 0.1s; - color: $primary-fg-color; + color: $primary-content; background-color: transparent; font-size: $font-14px; + transform: translateY(0); position: absolute; left: 0px; - top: 0px; margin: 7px 8px; padding: 2px; pointer-events: none; // Allow clicks to fall through to the input @@ -124,10 +124,10 @@ limitations under the License. transition: font-size 0.25s ease-out 0s, color 0.25s ease-out 0s, - top 0.25s ease-out 0s, + transform 0.25s ease-out 0s, background-color 0.25s ease-out 0s; font-size: $font-10px; - top: -13px; + transform: translateY(-13px); padding: 0 2px; background-color: $field-focused-label-bg-color; pointer-events: initial; diff --git a/res/css/views/elements/_FormButton.scss b/res/css/views/elements/_FormButton.scss deleted file mode 100644 index eda201ff03..0000000000 --- a/res/css/views/elements/_FormButton.scss +++ /dev/null @@ -1,42 +0,0 @@ -/* -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. -*/ - -.mx_FormButton { - line-height: $font-16px; - padding: 5px 15px; - font-size: $font-12px; - height: min-content; - - &:not(:last-child) { - margin-right: 8px; - } - - &.mx_AccessibleButton_kind_primary { - color: $accent-color; - background-color: $accent-bg-color; - } - - &.mx_AccessibleButton_kind_danger { - color: $notice-primary-color; - background-color: $notice-primary-bg-color; - } - - &.mx_AccessibleButton_kind_secondary { - color: $secondary-fg-color; - border: 1px solid $secondary-fg-color; - background-color: unset; - } -} diff --git a/res/css/views/elements/_ImageView.scss b/res/css/views/elements/_ImageView.scss index 71035dadc3..787d33ddc2 100644 --- a/res/css/views/elements/_ImageView.scss +++ b/res/css/views/elements/_ImageView.scss @@ -14,6 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ +$button-size: 32px; +$icon-size: 22px; +$button-gap: 24px; + +:root { + --image-view-panel-height: 68px; +} + .mx_ImageView { display: flex; width: 100%; @@ -22,6 +30,7 @@ limitations under the License. } .mx_ImageView_image_wrapper { + pointer-events: initial; display: flex; justify-content: center; align-items: center; @@ -30,20 +39,29 @@ limitations under the License. } .mx_ImageView_image { - pointer-events: all; flex-shrink: 0; + + &.mx_ImageView_image_animating { + transition: transform 200ms ease 0s; + } + + &.mx_ImageView_image_animatingLoading { + transition: transform 300ms ease 0s; + } } .mx_ImageView_panel { width: 100%; - height: 68px; + height: var(--image-view-panel-height); display: flex; justify-content: space-between; align-items: center; + animation-name: mx_ImageView_panel_keyframes; + animation-duration: 300ms; } .mx_ImageView_info_wrapper { - pointer-events: all; + pointer-events: initial; padding-left: 32px; display: flex; flex-direction: row; @@ -63,19 +81,20 @@ limitations under the License. .mx_ImageView_toolbar { padding-right: 16px; - pointer-events: all; + pointer-events: initial; display: flex; align-items: center; + gap: calc($button-gap - ($button-size - $icon-size)); } .mx_ImageView_button { - margin-left: 24px; + padding: calc(($button-size - $icon-size) / 2); display: block; &::before { content: ''; - height: 22px; - width: 22px; + height: $icon-size; + width: $icon-size; mask-repeat: no-repeat; mask-size: contain; mask-position: center; @@ -109,12 +128,23 @@ limitations under the License. } .mx_ImageView_button_close { + padding: calc($button-size - $button-size); border-radius: 100%; background: #21262c; // same on all themes &::before { - width: 32px; - height: 32px; + width: $button-size; + height: $button-size; mask-image: url('$(res)/img/image-view/close.svg'); mask-size: 40%; } } + +@media (prefers-reduced-motion) { + .mx_ImageView_image_animating { + transition: none !important; + } + + .mx_ImageView_image_animatingLoading { + transition: none !important; + } +} diff --git a/res/css/views/elements/_InfoTooltip.scss b/res/css/views/elements/_InfoTooltip.scss index 5858a60629..5329e7f1f8 100644 --- a/res/css/views/elements/_InfoTooltip.scss +++ b/res/css/views/elements/_InfoTooltip.scss @@ -30,5 +30,12 @@ limitations under the License. mask-position: center; content: ''; vertical-align: middle; +} + +.mx_InfoTooltip_icon_info::before { mask-image: url('$(res)/img/element-icons/info.svg'); } + +.mx_InfoTooltip_icon_warning::before { + mask-image: url('$(res)/img/element-icons/warning.svg'); +} diff --git a/res/css/views/elements/_InviteReason.scss b/res/css/views/elements/_InviteReason.scss index 2c2e5687e6..8024ed59a3 100644 --- a/res/css/views/elements/_InviteReason.scss +++ b/res/css/views/elements/_InviteReason.scss @@ -32,12 +32,12 @@ limitations under the License. justify-content: center; align-items: center; cursor: pointer; - color: $secondary-fg-color; + color: $secondary-content; &::before { content: ""; margin-right: 8px; - background-color: $secondary-fg-color; + background-color: $secondary-content; mask-image: url('$(res)/img/feather-customised/eye.svg'); display: inline-block; width: 18px; diff --git a/res/css/views/elements/_MiniAvatarUploader.scss b/res/css/views/elements/_MiniAvatarUploader.scss index df4676ab56..46ffd9a01c 100644 --- a/res/css/views/elements/_MiniAvatarUploader.scss +++ b/res/css/views/elements/_MiniAvatarUploader.scss @@ -37,7 +37,7 @@ limitations under the License. right: -6px; bottom: -6px; - background-color: $primary-bg-color; + background-color: $background; border-radius: 50%; z-index: 1; @@ -45,7 +45,7 @@ limitations under the License. height: 100%; width: 100%; - background-color: $secondary-fg-color; + background-color: $secondary-content; mask-position: center; mask-repeat: no-repeat; mask-image: url('$(res)/img/element-icons/camera.svg'); diff --git a/res/css/views/elements/_ReplyThread.scss b/res/css/views/elements/_ReplyThread.scss index bf44a11728..e19be82e25 100644 --- a/res/css/views/elements/_ReplyThread.scss +++ b/res/css/views/elements/_ReplyThread.scss @@ -16,22 +16,57 @@ limitations under the License. .mx_ReplyThread { margin-top: 0; -} - -.mx_ReplyThread .mx_DateSeparator { - font-size: 1em !important; - margin-top: 0; - margin-bottom: 0; - padding-bottom: 1px; - bottom: -5px; -} - -.mx_ReplyThread_show { - cursor: pointer; -} - -blockquote.mx_ReplyThread { margin-left: 0; - padding-left: 10px; - border-left: 4px solid $blockquote-bar-color; + margin-right: 0; + margin-bottom: 8px; + padding: 0 10px; + border-left: 2px solid $button-bg-color; + border-radius: 2px; + + .mx_ReplyThread_show { + cursor: pointer; + } + + &.mx_ReplyThread_color1 { + border-left-color: $username-variant1-color; + } + + &.mx_ReplyThread_color2 { + border-left-color: $username-variant2-color; + } + + &.mx_ReplyThread_color3 { + border-left-color: $username-variant3-color; + } + + &.mx_ReplyThread_color4 { + border-left-color: $username-variant4-color; + } + + &.mx_ReplyThread_color5 { + border-left-color: $username-variant5-color; + } + + &.mx_ReplyThread_color6 { + border-left-color: $username-variant6-color; + } + + &.mx_ReplyThread_color7 { + border-left-color: $username-variant7-color; + } + + &.mx_ReplyThread_color8 { + border-left-color: $username-variant8-color; + } +} + +.mx_ReplyThread--expanded { + .mx_EventTile_body { + display: block; + overflow-y: scroll !important; + } + .mx_EventTile_collapsedCodeBlock { + // !important needed due to .mx_ReplyTile .mx_EventTile_content .mx_EventTile_pre_container > pre + display: block !important; + } } diff --git a/res/css/views/elements/_RichText.scss b/res/css/views/elements/_RichText.scss index d60282695c..1043fd08d1 100644 --- a/res/css/views/elements/_RichText.scss +++ b/res/css/views/elements/_RichText.scss @@ -18,7 +18,7 @@ a.mx_Pill { text-overflow: ellipsis; white-space: nowrap; overflow: hidden; - max-width: calc(100% - 1ch); + max-width: 100%; } .mx_Pill { @@ -43,7 +43,7 @@ a.mx_Pill { /* More specific to override `.markdown-body a` color */ .mx_EventTile_content .markdown-body a.mx_UserPill, .mx_UserPill { - color: $primary-fg-color; + color: $primary-content; background-color: $other-user-pill-bg-color; } diff --git a/res/css/views/elements/_SSOButtons.scss b/res/css/views/elements/_SSOButtons.scss index e02816780f..a98e7b4024 100644 --- a/res/css/views/elements/_SSOButtons.scss +++ b/res/css/views/elements/_SSOButtons.scss @@ -35,7 +35,7 @@ limitations under the License. font-size: $font-14px; font-weight: $font-semi-bold; border: 1px solid $input-border-color; - color: $primary-fg-color; + color: $primary-content; > img { object-fit: contain; diff --git a/res/css/views/elements/_ServerPicker.scss b/res/css/views/elements/_ServerPicker.scss index 188eb5d655..d828d7cb88 100644 --- a/res/css/views/elements/_ServerPicker.scss +++ b/res/css/views/elements/_ServerPicker.scss @@ -74,7 +74,7 @@ limitations under the License. .mx_ServerPicker_desc { margin-top: -12px; - color: $tertiary-fg-color; + color: $tertiary-content; grid-column: 1 / 2; grid-row: 3; margin-bottom: 16px; diff --git a/res/css/views/elements/_Spinner.scss b/res/css/views/elements/_Spinner.scss index 93d5e2d96c..2df46687af 100644 --- a/res/css/views/elements/_Spinner.scss +++ b/res/css/views/elements/_Spinner.scss @@ -37,7 +37,7 @@ limitations under the License. } .mx_Spinner_icon { - background-color: $primary-fg-color; + background-color: $primary-content; mask: url('$(res)/img/spinner.svg'); mask-size: contain; animation: 1.1s steps(12, end) infinite spin; diff --git a/res/css/views/elements/_StyledRadioButton.scss b/res/css/views/elements/_StyledRadioButton.scss index 62fb5c5512..1ae787dfc2 100644 --- a/res/css/views/elements/_StyledRadioButton.scss +++ b/res/css/views/elements/_StyledRadioButton.scss @@ -46,7 +46,7 @@ limitations under the License. width: $font-16px; } - > input[type=radio] { + input[type=radio] { // Remove the OS's representation margin: 0; padding: 0; @@ -112,6 +112,12 @@ limitations under the License. } } } + + .mx_RadioButton_innerLabel { + display: flex; + position: relative; + top: 4px; + } } .mx_RadioButton_outlined { diff --git a/res/css/views/elements/_TagComposer.scss b/res/css/views/elements/_TagComposer.scss new file mode 100644 index 0000000000..f5bdb8d2d5 --- /dev/null +++ b/res/css/views/elements/_TagComposer.scss @@ -0,0 +1,77 @@ +/* +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. +*/ + +.mx_TagComposer { + .mx_TagComposer_input { + display: flex; + + .mx_Field { + flex: 1; + margin: 0; // override from field styles + } + + .mx_AccessibleButton { + min-width: 70px; + padding: 0 8px; // override from button styles + margin-left: 16px; // distance from + } + + .mx_Field, .mx_Field input, .mx_AccessibleButton { + // So they look related to each other by feeling the same + border-radius: 8px; + } + } + + .mx_TagComposer_tags { + display: flex; + flex-wrap: wrap; + margin-top: 12px; // this plus 12px from the tags makes 24px from the input + + .mx_TagComposer_tag { + padding: 6px 8px 8px 12px; + position: relative; + margin-right: 12px; + margin-top: 12px; + + // Cheaty way to get an opacified variable colour background + &::before { + content: ''; + border-radius: 20px; + background-color: $tertiary-content; + opacity: 0.15; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + + // Pass through the pointer otherwise we have effectively put a whole div + // on top of the component, which makes it hard to interact with buttons. + pointer-events: none; + } + } + + .mx_AccessibleButton { + background-image: url('$(res)/img/subtract.svg'); + width: 16px; + height: 16px; + margin-left: 8px; + display: inline-block; + vertical-align: middle; + cursor: pointer; + } + } +} diff --git a/res/css/views/elements/_Tooltip.scss b/res/css/views/elements/_Tooltip.scss index d90c818f94..6c5a7da55a 100644 --- a/res/css/views/elements/_Tooltip.scss +++ b/res/css/views/elements/_Tooltip.scss @@ -84,7 +84,7 @@ limitations under the License. // These tooltips use an older style with a chevron .mx_Field_tooltip { background-color: $menu-bg-color; - color: $primary-fg-color; + color: $primary-content; border: 1px solid $menu-border-color; text-align: unset; diff --git a/res/css/views/emojipicker/_EmojiPicker.scss b/res/css/views/emojipicker/_EmojiPicker.scss index 400e40e233..91c68158c9 100644 --- a/res/css/views/emojipicker/_EmojiPicker.scss +++ b/res/css/views/emojipicker/_EmojiPicker.scss @@ -57,7 +57,7 @@ limitations under the License. } .mx_EmojiPicker_anchor::before { - background-color: $primary-fg-color; + background-color: $primary-content; content: ''; display: inline-block; mask-size: 100%; @@ -89,7 +89,7 @@ limitations under the License. margin: 8px; border-radius: 4px; border: 1px solid $input-border-color; - background-color: $primary-bg-color; + background-color: $background; display: flex; input { @@ -126,7 +126,7 @@ limitations under the License. .mx_EmojiPicker_search_icon::after { mask: url('$(res)/img/emojipicker/search.svg') no-repeat; mask-size: 100%; - background-color: $primary-fg-color; + background-color: $primary-content; content: ''; display: inline-block; width: 100%; diff --git a/res/css/views/groups/_GroupRoomList.scss b/res/css/views/groups/_GroupRoomList.scss index fefd17849c..2f6559f7c4 100644 --- a/res/css/views/groups/_GroupRoomList.scss +++ b/res/css/views/groups/_GroupRoomList.scss @@ -16,7 +16,7 @@ limitations under the License. .mx_GroupRoomTile { position: relative; - color: $primary-fg-color; + color: $primary-content; cursor: pointer; display: flex; align-items: center; diff --git a/res/css/views/messages/_CallEvent.scss b/res/css/views/messages/_CallEvent.scss new file mode 100644 index 0000000000..7934f8f3c2 --- /dev/null +++ b/res/css/views/messages/_CallEvent.scss @@ -0,0 +1,219 @@ +/* +Copyright 2021 Šimon Brandner + +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_CallEvent_wrapper { + display: flex; + width: 100%; + + .mx_CallEvent { + position: relative; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + + background-color: $dark-panel-bg-color; + border-radius: 8px; + width: 65%; + box-sizing: border-box; + height: 60px; + margin: 4px 0; + + .mx_CallEvent_iconButton { + display: inline-flex; + margin-right: 8px; + + &::before { + content: ''; + + height: 16px; + width: 16px; + background-color: $secondary-content; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + } + } + + .mx_CallEvent_silence::before { + mask-image: url('$(res)/img/voip/silence.svg'); + } + + .mx_CallEvent_unSilence::before { + mask-image: url('$(res)/img/voip/un-silence.svg'); + } + + &.mx_CallEvent_voice { + .mx_CallEvent_type_icon::before, + .mx_CallEvent_content_button_callBack span::before, + .mx_CallEvent_content_button_answer span::before { + mask-image: url('$(res)/img/element-icons/call/voice-call.svg'); + } + } + + &.mx_CallEvent_video { + .mx_CallEvent_type_icon::before, + .mx_CallEvent_content_button_callBack span::before, + .mx_CallEvent_content_button_answer span::before { + mask-image: url('$(res)/img/element-icons/call/video-call.svg'); + } + } + + &.mx_CallEvent_voice.mx_CallEvent_missed .mx_CallEvent_type_icon::before { + mask-image: url('$(res)/img/voip/missed-voice.svg'); + } + + &.mx_CallEvent_video.mx_CallEvent_missed .mx_CallEvent_type_icon::before { + mask-image: url('$(res)/img/voip/missed-video.svg'); + } + + &.mx_CallEvent_voice.mx_CallEvent_rejected .mx_CallEvent_type_icon::before, + &.mx_CallEvent_voice.mx_CallEvent_noAnswer .mx_CallEvent_type_icon::before { + mask-image: url('$(res)/img/voip/declined-voice.svg'); + } + + &.mx_CallEvent_video.mx_CallEvent_rejected .mx_CallEvent_type_icon::before, + &.mx_CallEvent_video.mx_CallEvent_noAnswer .mx_CallEvent_type_icon::before { + mask-image: url('$(res)/img/voip/declined-video.svg'); + } + + .mx_CallEvent_info { + display: flex; + flex-direction: row; + align-items: center; + margin-left: 12px; + min-width: 0; + + .mx_CallEvent_info_basic { + display: flex; + flex-direction: column; + margin-left: 10px; // To match mx_CallEvent + min-width: 0; + + .mx_CallEvent_sender { + font-weight: 600; + font-size: 1.5rem; + line-height: 1.8rem; + margin-bottom: 3px; + + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .mx_CallEvent_type { + font-weight: 400; + color: $secondary-content; + font-size: 1.2rem; + line-height: $font-13px; + display: flex; + align-items: center; + + .mx_CallEvent_type_icon { + height: 13px; + width: 13px; + margin-right: 5px; + + &::before { + content: ''; + position: absolute; + height: 13px; + width: 13px; + background-color: $secondary-content; + mask-repeat: no-repeat; + mask-size: contain; + } + } + } + } + } + + .mx_CallEvent_content { + display: flex; + flex-direction: row; + align-items: center; + color: $secondary-content; + margin-right: 16px; + gap: 8px; + min-width: max-content; + + .mx_CallEvent_content_button { + padding: 0px 12px; + + span { + padding: 1px 0; + display: flex; + align-items: center; + + &::before { + content: ''; + display: inline-block; + background-color: $button-fg-color; + mask-position: center; + mask-repeat: no-repeat; + mask-size: 16px; + width: 16px; + height: 16px; + margin-right: 8px; + + flex-shrink: 0; + } + } + } + + .mx_CallEvent_content_button_reject span::before { + mask-image: url('$(res)/img/element-icons/call/hangup.svg'); + } + + .mx_CallEvent_content_tooltip { + margin-right: 5px; + } + } + + &.mx_CallEvent_narrow { + height: unset; + width: 290px; + flex-direction: column; + align-items: unset; + gap: 16px; + + .mx_CallEvent_iconButton { + position: absolute; + margin-right: 0; + top: 12px; + right: 12px; + height: 16px; + width: 16px; + display: flex; + } + + .mx_CallEvent_info { + align-items: unset; + margin-top: 12px; + margin-right: 12px; + + .mx_CallEvent_sender { + margin-bottom: 8px; + } + } + + .mx_CallEvent_content { + margin-left: 54px; // mx_CallEvent margin (12px) + avatar (32px) + mx_CallEvent_info_basic margin (10px) + margin-bottom: 16px; + } + } + } +} diff --git a/res/css/views/messages/_MFileBody.scss b/res/css/views/messages/_MFileBody.scss index c215d69ec2..e23696e6a9 100644 --- a/res/css/views/messages/_MFileBody.scss +++ b/res/css/views/messages/_MFileBody.scss @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016, 2021 The Matrix.org Foundation C.I.C. +Copyright 2015 - 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. @@ -60,14 +60,10 @@ limitations under the License. } .mx_MFileBody_info { - background-color: $message-body-panel-bg-color; - border-radius: 12px; - width: 243px; // same width as a playable voice message, accounting for padding - padding: 6px 12px; - color: $message-body-panel-fg-color; + cursor: pointer; .mx_MFileBody_info_icon { - background-color: $message-body-panel-icon-bg-color; + background-color: $system; border-radius: 20px; display: inline-block; width: 32px; @@ -82,13 +78,13 @@ limitations under the License. mask-position: center; mask-size: cover; mask-image: url('$(res)/img/element-icons/room/composer/attach.svg'); - background-color: $message-body-panel-icon-fg-color; - width: 13px; + background-color: $secondary-content; + width: 15px; height: 15px; position: absolute; top: 8px; - left: 9px; + left: 8px; } } diff --git a/res/css/views/messages/_MImageBody.scss b/res/css/views/messages/_MImageBody.scss index 1c773c2f06..920c3011f5 100644 --- a/res/css/views/messages/_MImageBody.scss +++ b/res/css/views/messages/_MImageBody.scss @@ -14,18 +14,32 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_MImageBody { - display: block; - margin-right: 34px; +$timelineImageBorderRadius: 4px; + +.mx_MImageBody_thumbnail--blurhash { + position: absolute; + left: 0; + top: 0; } .mx_MImageBody_thumbnail { - position: absolute; - width: 100%; + object-fit: contain; + border-radius: $timelineImageBorderRadius; + + display: flex; + justify-content: center; + align-items: center; height: 100%; - left: 0; - top: 0; - border-radius: 4px; + width: 100%; + + .mx_Blurhash > canvas { + animation: mx--anim-pulse 1.75s infinite cubic-bezier(.4, 0, .6, 1); + border-radius: $timelineImageBorderRadius; + } + + .mx_no-image-placeholder { + background-color: $primary-content; + } } .mx_MImageBody_thumbnail_container { @@ -37,17 +51,6 @@ limitations under the License. position: relative; } -.mx_MImageBody_thumbnail_spinner { - position: absolute; - left: 50%; - top: 50%; -} - -// Inner img and TintableSvg should be centered around 0, 0 -.mx_MImageBody_thumbnail_spinner > * { - transform: translate(-50%, -50%); -} - .mx_MImageBody_gifLabel { position: absolute; display: block; @@ -97,5 +100,5 @@ limitations under the License. } .mx_EventTile:hover .mx_HiddenImagePlaceholder { - background-color: $primary-bg-color; + background-color: $background; } diff --git a/res/css/views/messages/_MImageReplyBody.scss b/res/css/views/messages/_MImageReplyBody.scss new file mode 100644 index 0000000000..70c53f8c9c --- /dev/null +++ b/res/css/views/messages/_MImageReplyBody.scss @@ -0,0 +1,37 @@ +/* +Copyright 2020 Tulir Asokan + +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_MImageReplyBody { + display: flex; + + .mx_MImageBody_thumbnail_container { + flex: 1; + margin-right: 4px; + } + + .mx_MImageReplyBody_info { + flex: 1; + + .mx_MImageReplyBody_sender { + grid-area: sender; + } + + .mx_MImageReplyBody_filename { + grid-area: filename; + } + } +} + diff --git a/res/css/views/messages/_MediaBody.scss b/res/css/views/messages/_MediaBody.scss new file mode 100644 index 0000000000..874de05c71 --- /dev/null +++ b/res/css/views/messages/_MediaBody.scss @@ -0,0 +1,30 @@ +/* +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. +*/ + +// A "media body" is any file upload looking thing, apart from images and videos (they +// have unique styles). + +.mx_MediaBody { + background-color: $quinary-content; + border-radius: 12px; + max-width: 243px; // use max-width instead of width so it fits within right panels + + color: $secondary-content; + font-size: $font-14px; + line-height: $font-24px; + + padding: 6px 12px; +} diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index e2fafe6c62..46fc11956f 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -23,7 +23,7 @@ limitations under the License. height: 32px; line-height: $font-24px; border-radius: 8px; - background: $primary-bg-color; + background: $background; border: 1px solid $input-border-color; top: -32px; right: 8px; @@ -77,11 +77,11 @@ limitations under the License. mask-size: 18px; mask-repeat: no-repeat; mask-position: center; - background-color: $secondary-fg-color; + background-color: $secondary-content; } .mx_MessageActionBar_maskButton:hover::after { - background-color: $primary-fg-color; + background-color: $primary-content; } .mx_MessageActionBar_reactButton::after { @@ -92,6 +92,10 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/room/message-bar/reply.svg'); } +.mx_MessageActionBar_threadButton::after { + mask-image: url('$(res)/img/element-icons/message/thread.svg'); +} + .mx_MessageActionBar_editButton::after { mask-image: url('$(res)/img/element-icons/room/message-bar/edit.svg'); } @@ -107,3 +111,22 @@ limitations under the License. .mx_MessageActionBar_cancelButton::after { mask-image: url('$(res)/img/element-icons/trashcan.svg'); } + +.mx_MessageActionBar_downloadButton::after { + mask-size: 14px; + mask-image: url('$(res)/img/download.svg'); +} + +.mx_MessageActionBar_expandMessageButton::after { + mask-size: 12px; + mask-image: url('$(res)/img/element-icons/expand-message.svg'); +} + +.mx_MessageActionBar_collapseMessageButton::after { + mask-size: 12px; + mask-image: url('$(res)/img/element-icons/collapse-message.svg'); +} + +.mx_MessageActionBar_downloadButton.mx_MessageActionBar_downloadSpinnerButton::after { + background-color: transparent; // hide the download icon mask +} diff --git a/res/css/views/messages/_ReactionsRow.scss b/res/css/views/messages/_ReactionsRow.scss index e05065eb02..1b0b847932 100644 --- a/res/css/views/messages/_ReactionsRow.scss +++ b/res/css/views/messages/_ReactionsRow.scss @@ -16,7 +16,7 @@ limitations under the License. .mx_ReactionsRow { margin: 6px 0; - color: $primary-fg-color; + color: $primary-content; .mx_ReactionsRow_addReactionButton { position: relative; @@ -26,6 +26,7 @@ limitations under the License. height: 24px; vertical-align: middle; margin-left: 4px; + margin-right: 4px; &::before { content: ''; @@ -35,7 +36,7 @@ limitations under the License. mask-size: 16px; mask-repeat: no-repeat; mask-position: center; - background-color: $tertiary-fg-color; + background-color: $tertiary-content; mask-image: url('$(res)/img/element-icons/room/message-bar/emoji.svg'); } @@ -45,7 +46,7 @@ limitations under the License. &:hover, &.mx_ReactionsRow_addReactionButton_active { &::before { - background-color: $primary-fg-color; + background-color: $primary-content; } } } @@ -63,10 +64,10 @@ limitations under the License. vertical-align: middle; &:link, &:visited { - color: $tertiary-fg-color; + color: $tertiary-content; } &:hover { - color: $primary-fg-color; + color: $primary-content; } } diff --git a/res/css/views/messages/_SenderProfile.scss b/res/css/views/messages/_SenderProfile.scss index 655cb39489..08644b14e3 100644 --- a/res/css/views/messages/_SenderProfile.scss +++ b/res/css/views/messages/_SenderProfile.scss @@ -14,7 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_SenderProfile_name { +.mx_SenderProfile_displayName { font-weight: 600; } +.mx_SenderProfile_mxid { + font-weight: 600; + font-size: 1.1rem; + margin-left: 5px; + opacity: 0.5; // Match mx_TextualEvent +} diff --git a/res/css/views/messages/_TextualEvent.scss b/res/css/views/messages/_TextualEvent.scss index be7565b3c5..e87fed90de 100644 --- a/res/css/views/messages/_TextualEvent.scss +++ b/res/css/views/messages/_TextualEvent.scss @@ -17,4 +17,9 @@ limitations under the License. .mx_TextualEvent { opacity: 0.5; overflow-y: hidden; + + a { + color: $accent-color; + cursor: pointer; + } } diff --git a/res/css/views/messages/_ViewSourceEvent.scss b/res/css/views/messages/_ViewSourceEvent.scss index 66825030e0..b0e40a5152 100644 --- a/res/css/views/messages/_ViewSourceEvent.scss +++ b/res/css/views/messages/_ViewSourceEvent.scss @@ -43,8 +43,10 @@ limitations under the License. margin-bottom: 7px; mask-image: url('$(res)/img/feather-customised/minimise.svg'); } +} - &:hover .mx_ViewSourceEvent_toggle { +.mx_EventTile:hover { + .mx_ViewSourceEvent_toggle { visibility: visible; } } diff --git a/res/css/views/messages/_common_CryptoEvent.scss b/res/css/views/messages/_common_CryptoEvent.scss index 4faa4b594f..afaed50fa4 100644 --- a/res/css/views/messages/_common_CryptoEvent.scss +++ b/res/css/views/messages/_common_CryptoEvent.scss @@ -21,7 +21,7 @@ limitations under the License. mask-image: url('$(res)/img/e2e/normal.svg'); mask-repeat: no-repeat; mask-position: center; - mask-size: 90%; + mask-size: 80%; } &.mx_cryptoEvent_icon::after { @@ -48,6 +48,7 @@ limitations under the License. .mx_cryptoEvent_buttons { align-items: center; display: flex; + gap: 5px; } .mx_cryptoEvent_state { diff --git a/res/css/views/right_panel/_BaseCard.scss b/res/css/views/right_panel/_BaseCard.scss index 9a5a59bda8..8c1a55fe05 100644 --- a/res/css/views/right_panel/_BaseCard.scss +++ b/res/css/views/right_panel/_BaseCard.scss @@ -93,7 +93,7 @@ limitations under the License. } > h1 { - color: $tertiary-fg-color; + color: $tertiary-content; font-size: $font-12px; font-weight: 500; } @@ -145,7 +145,7 @@ limitations under the License. justify-content: space-around; .mx_AccessibleButton_kind_secondary { - color: $secondary-fg-color; + color: $secondary-content; background-color: rgba(141, 151, 165, 0.2); font-weight: $font-semi-bold; font-size: $font-14px; diff --git a/res/css/views/right_panel/_PinnedMessagesCard.scss b/res/css/views/right_panel/_PinnedMessagesCard.scss index b6b8238bed..f3861a3dec 100644 --- a/res/css/views/right_panel/_PinnedMessagesCard.scss +++ b/res/css/views/right_panel/_PinnedMessagesCard.scss @@ -32,4 +32,59 @@ limitations under the License. margin-right: 6px; } } + + .mx_PinnedMessagesCard_empty { + display: flex; + height: 100%; + + > div { + height: max-content; + text-align: center; + margin: auto 40px; + + .mx_PinnedMessagesCard_MessageActionBar { + pointer-events: none; + display: flex; + height: 32px; + line-height: $font-24px; + border-radius: 8px; + background: $background; + border: 1px solid $input-border-color; + padding: 1px; + width: max-content; + margin: 0 auto; + box-sizing: border-box; + + .mx_MessageActionBar_maskButton { + display: inline-block; + position: relative; + } + + .mx_MessageActionBar_optionsButton { + background: $roomlist-button-bg-color; + border-radius: 6px; + z-index: 1; + + &::after { + background-color: $primary-content; + } + } + } + + > h2 { + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-24px; + color: $primary-content; + margin-top: 24px; + margin-bottom: 20px; + } + + > span { + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-content; + } + } + } } diff --git a/res/css/views/right_panel/_RoomSummaryCard.scss b/res/css/views/right_panel/_RoomSummaryCard.scss index dc7804d072..c137bb7677 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.scss +++ b/res/css/views/right_panel/_RoomSummaryCard.scss @@ -27,7 +27,7 @@ limitations under the License. .mx_RoomSummaryCard_alias { font-size: $font-13px; - color: $secondary-fg-color; + color: $secondary-content; } h2, .mx_RoomSummaryCard_alias { @@ -115,7 +115,7 @@ limitations under the License. // as we will be applying it in its children padding: 0; height: auto; - color: $tertiary-fg-color; + color: $tertiary-content; .mx_RoomSummaryCard_icon_app { padding: 10px 48px 10px 12px; // based on typical mx_RoomSummaryCard_Button padding @@ -128,7 +128,7 @@ limitations under the License. } span { - color: $primary-fg-color; + color: $primary-content; } } @@ -232,6 +232,10 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/room/files.svg'); } +.mx_RoomSummaryCard_icon_threads::before { + mask-image: url('$(res)/img/element-icons/message/thread.svg'); +} + .mx_RoomSummaryCard_icon_share::before { mask-image: url('$(res)/img/element-icons/room/share.svg'); } @@ -239,3 +243,7 @@ limitations under the License. .mx_RoomSummaryCard_icon_settings::before { mask-image: url('$(res)/img/element-icons/settings.svg'); } + +.mx_RoomSummaryCard_icon_export::before { + mask-image: url('$(res)/img/element-icons/export.svg'); +} diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss index 87420ae4e7..edc82cfdbf 100644 --- a/res/css/views/right_panel/_UserInfo.scss +++ b/res/css/views/right_panel/_UserInfo.scss @@ -55,7 +55,7 @@ limitations under the License. } .mx_UserInfo_separator { - border-bottom: 1px solid rgba($primary-fg-color, .1); + border-bottom: 1px solid rgba($primary-content, .1); } .mx_UserInfo_memberDetailsContainer { @@ -259,16 +259,6 @@ limitations under the License. .mx_AccessibleButton.mx_AccessibleButton_hasKind { padding: 8px 18px; - - &.mx_AccessibleButton_kind_primary { - color: $accent-color; - background-color: $accent-bg-color; - } - - &.mx_AccessibleButton_kind_danger { - color: $notice-primary-color; - background-color: $notice-primary-bg-color; - } } .mx_VerificationShowSas .mx_AccessibleButton, diff --git a/res/css/views/right_panel/_VerificationPanel.scss b/res/css/views/right_panel/_VerificationPanel.scss index a8466a1626..95856a5d69 100644 --- a/res/css/views/right_panel/_VerificationPanel.scss +++ b/res/css/views/right_panel/_VerificationPanel.scss @@ -58,7 +58,7 @@ limitations under the License. } .mx_VerificationPanel_reciprocate_section { - .mx_FormButton { + .mx_AccessibleButton { width: 100%; box-sizing: border-box; padding: 10px; @@ -87,7 +87,7 @@ limitations under the License. } .mx_VerificationPanel_QRPhase_startOption { - background-color: $user-tile-hover-bg-color; + background-color: $header-panel-bg-color; border-radius: 10px; flex: 1; display: flex; diff --git a/res/css/views/right_panel/_WidgetCard.scss b/res/css/views/right_panel/_WidgetCard.scss index a90e744a5a..824f1fcb2f 100644 --- a/res/css/views/right_panel/_WidgetCard.scss +++ b/res/css/views/right_panel/_WidgetCard.scss @@ -51,7 +51,7 @@ limitations under the License. mask-position: center; mask-size: contain; mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); - background-color: $secondary-fg-color; + background-color: $secondary-content; } } } diff --git a/res/css/views/rooms/_AppsDrawer.scss b/res/css/views/rooms/_AppsDrawer.scss index fd80836237..cfcb0c48a2 100644 --- a/res/css/views/rooms/_AppsDrawer.scss +++ b/res/css/views/rooms/_AppsDrawer.scss @@ -64,7 +64,7 @@ $MiniAppTileHeight: 200px; &:hover { .mx_AppsContainer_resizerHandle::after { opacity: 0.8; - background: $primary-fg-color; + background: $primary-content; } .mx_ResizeHandle_horizontal::before { @@ -79,7 +79,7 @@ $MiniAppTileHeight: 200px; content: ''; - background-color: $primary-fg-color; + background-color: $primary-content; opacity: 0.8; } } diff --git a/res/css/views/rooms/_Autocomplete.scss b/res/css/views/rooms/_Autocomplete.scss index f8e0a382b1..fcdab37f5a 100644 --- a/res/css/views/rooms/_Autocomplete.scss +++ b/res/css/views/rooms/_Autocomplete.scss @@ -4,27 +4,29 @@ z-index: 1001; width: 100%; border: 1px solid $primary-hairline-color; - background: $primary-bg-color; + background: $background; border-bottom: none; border-radius: 8px 8px 0 0; - max-height: 50vh; - overflow: auto; + overflow: clip; + display: flex; + flex-direction: column; box-shadow: 0px -16px 32px $composer-shadow-color; } .mx_Autocomplete_ProviderSection { border-bottom: 1px solid $primary-hairline-color; + width: 100%; } /* a "block" completion takes up a whole line */ .mx_Autocomplete_Completion_block { - height: 34px; + min-height: 34px; display: flex; padding: 0 12px; user-select: none; cursor: pointer; align-items: center; - color: $primary-fg-color; + color: $primary-content; } .mx_Autocomplete_Completion_block * { @@ -40,7 +42,7 @@ user-select: none; cursor: pointer; align-items: center; - color: $primary-fg-color; + color: $primary-content; } .mx_Autocomplete_Completion_pill > * { @@ -59,8 +61,9 @@ .mx_Autocomplete_Completion_container_pill { margin: 12px; - display: flex; - flex-flow: wrap; + height: 100%; + overflow-y: scroll; + max-height: 35vh; } .mx_Autocomplete_Completion_container_truncate { @@ -68,7 +71,6 @@ .mx_Autocomplete_Completion_subtitle, .mx_Autocomplete_Completion_description { /* Ellipsis for long names/subtitles/descriptions */ - max-width: 150px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -83,7 +85,7 @@ .mx_Autocomplete_provider_name { margin: 12px; - color: $primary-fg-color; + color: $primary-content; font-weight: 400; opacity: 0.4; } diff --git a/res/css/views/rooms/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss index e1ba468204..752d3b0a54 100644 --- a/res/css/views/rooms/_BasicMessageComposer.scss +++ b/res/css/views/rooms/_BasicMessageComposer.scss @@ -31,7 +31,7 @@ limitations under the License. @keyframes visualbell { from { background-color: $visual-bell-bg-color; } - to { background-color: $primary-bg-color; } + to { background-color: $background; } } &.mx_BasicMessageComposer_input_error { @@ -65,6 +65,14 @@ limitations under the License. font-size: $font-10-4px; } } + + span.mx_UserPill { + cursor: pointer; + } + + span.mx_RoomPill { + cursor: default; + } } &.mx_BasicMessageComposer_input_disabled { diff --git a/res/css/views/rooms/_E2EIcon.scss b/res/css/views/rooms/_E2EIcon.scss index a3473dedec..68ad44cf6a 100644 --- a/res/css/views/rooms/_E2EIcon.scss +++ b/res/css/views/rooms/_E2EIcon.scss @@ -45,7 +45,7 @@ limitations under the License. mask-image: url('$(res)/img/e2e/normal.svg'); mask-repeat: no-repeat; mask-position: center; - mask-size: 90%; + mask-size: 80%; } // transparent-looking border surrounding the shield for when overlain over avatars @@ -59,7 +59,7 @@ limitations under the License. } // shrink the infill of the badge &::before { - mask-size: 65%; + mask-size: 60%; } } diff --git a/res/css/views/rooms/_EditMessageComposer.scss b/res/css/views/rooms/_EditMessageComposer.scss index 214bfc4a1a..bf3c7c9b42 100644 --- a/res/css/views/rooms/_EditMessageComposer.scss +++ b/res/css/views/rooms/_EditMessageComposer.scss @@ -28,7 +28,7 @@ limitations under the License. .mx_BasicMessageComposer_input { border-radius: 4px; border: solid 1px $primary-hairline-color; - background-color: $primary-bg-color; + background-color: $background; max-height: 200px; padding: 3px 6px; diff --git a/res/css/views/rooms/_EntityTile.scss b/res/css/views/rooms/_EntityTile.scss index 27a4e67089..a2ebd6c11b 100644 --- a/res/css/views/rooms/_EntityTile.scss +++ b/res/css/views/rooms/_EntityTile.scss @@ -18,7 +18,7 @@ limitations under the License. .mx_EntityTile { display: flex; align-items: center; - color: $primary-fg-color; + color: $primary-content; cursor: pointer; .mx_E2EIcon { @@ -86,12 +86,12 @@ limitations under the License. .mx_EntityTile_ellipsis .mx_EntityTile_name { font-style: italic; - color: $primary-fg-color; + color: $primary-content; } .mx_EntityTile_invitePlaceholder .mx_EntityTile_name { font-style: italic; - color: $primary-fg-color; + color: $primary-content; } .mx_EntityTile_unavailable .mx_EntityTile_avatar, diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss new file mode 100644 index 0000000000..389a5c9706 --- /dev/null +++ b/res/css/views/rooms/_EventBubbleTile.scss @@ -0,0 +1,360 @@ +/* +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. +*/ + +.mx_EventTile[data-layout=bubble], +.mx_EventListSummary[data-layout=bubble] { + --avatarSize: 32px; + --gutterSize: 11px; + --cornerRadius: 12px; + --maxWidth: 70%; +} + +.mx_EventTile[data-layout=bubble] { + position: relative; + margin-top: var(--gutterSize); + margin-left: 49px; + margin-right: 100px; + font-size: $font-14px; + + &.mx_EventTile_continuation { + margin-top: 2px; + } + + &.mx_EventTile_highlight { + &::before { + background-color: $event-highlight-bg-color; + } + + color: $event-highlight-fg-color; + } + + /* For replies */ + .mx_EventTile { + padding-top: 0; + } + + &::before { + content: ''; + position: absolute; + top: -1px; + bottom: -1px; + left: -60px; + right: -60px; + z-index: -1; + border-radius: 4px; + } + + &:hover, + &.mx_EventTile_selected { + + &::before { + background: $eventbubble-bg-hover; + } + + .mx_EventTile_avatar { + img { + box-shadow: 0 0 0 3px $eventbubble-bg-hover; + } + } + } + + .mx_SenderProfile, + .mx_EventTile_line { + width: fit-content; + max-width: 70%; + } + + > .mx_SenderProfile { + position: relative; + top: -2px; + left: 2px; + font-size: $font-15px; + } + + &[data-self=false] { + .mx_EventTile_line { + border-bottom-right-radius: var(--cornerRadius); + } + .mx_EventTile_avatar { + left: -34px; + } + + .mx_MessageActionBar { + right: 0; + transform: translate3d(90%, 50%, 0); + } + + --backgroundColor: $eventbubble-others-bg; + } + &[data-self=true] { + .mx_EventTile_line { + border-bottom-left-radius: var(--cornerRadius); + float: right; + > a { + left: auto; + right: -68px; + } + } + .mx_SenderProfile { + display: none; + } + + .mx_ReplyTile .mx_SenderProfile { + display: block; + } + + .mx_ReactionsRow { + float: right; + clear: right; + display: flex; + + /* Moving the "add reaction button" before the reactions */ + > :last-child { + order: -1; + } + } + .mx_EventTile_avatar { + top: -19px; // height of the sender block + right: -35px; + } + + --backgroundColor: $eventbubble-self-bg; + } + + .mx_EventTile_line { + position: relative; + padding: var(--gutterSize); + border-top-left-radius: var(--cornerRadius); + border-top-right-radius: var(--cornerRadius); + background: var(--backgroundColor); + display: flex; + gap: 5px; + margin: 0 -12px 0 -9px; + > a { + position: absolute; + padding: 10px 20px; + top: 0; + left: -68px; + } + } + + &.mx_EventTile_continuation[data-self=false] .mx_EventTile_line { + border-top-left-radius: 0; + } + &.mx_EventTile_lastInSection[data-self=false] .mx_EventTile_line { + border-bottom-left-radius: var(--cornerRadius); + } + + &.mx_EventTile_continuation[data-self=true] .mx_EventTile_line { + border-top-right-radius: 0; + } + &.mx_EventTile_lastInSection[data-self=true] .mx_EventTile_line { + border-bottom-right-radius: var(--cornerRadius); + } + + .mx_EventTile_avatar { + position: absolute; + top: 0; + line-height: 1; + z-index: 9; + img { + box-shadow: 0 0 0 3px $eventbubble-avatar-outline; + border-radius: 50%; + } + } + + &.mx_EventTile_noSender { + .mx_EventTile_avatar { + top: -19px; + } + } + + .mx_BaseAvatar, + .mx_EventTile_avatar { + line-height: 1; + } + + &[data-has-reply=true] { + > .mx_EventTile_line { + flex-direction: column; + } + + .mx_ReplyThread_show { + order: 99999; + } + + .mx_ReplyThread { + .mx_EventTile_reply { + max-width: 90%; + padding: 0; + > a { + display: none !important; + } + } + + .mx_EventTile { + display: flex; + gap: var(--gutterSize); + .mx_EventTile_avatar { + position: static; + } + .mx_SenderProfile { + display: none; + } + } + } + } + + .mx_EditMessageComposer_buttons { + position: static; + padding: 0; + margin: 0; + background: transparent; + } + + .mx_ReactionsRow { + margin-right: -18px; + margin-left: -9px; + } + + /* Special layout scenario for "Unable To Decrypt (UTD)" events */ + &.mx_EventTile_bad > .mx_EventTile_line { + display: grid; + grid-template: + "reply reply" auto + "shield body" auto + "shield link" auto + / auto 1fr; + .mx_EventTile_e2eIcon { + grid-area: shield; + } + .mx_UnknownBody { + grid-area: body; + } + .mx_EventTile_keyRequestInfo { + grid-area: link; + } + .mx_ReplyThread_wrapper { + grid-area: reply; + } + } + + + .mx_EventTile_readAvatars { + position: absolute; + right: -110px; + bottom: 0; + top: auto; + } + + .mx_MTextBody { + max-width: 100%; + } +} + +.mx_EventTile.mx_EventTile_bubbleContainer[data-layout=bubble], +.mx_EventTile.mx_EventTile_leftAlignedBubble[data-layout=bubble], +.mx_EventTile.mx_EventTile_info[data-layout=bubble], +.mx_EventListSummary[data-layout=bubble][data-expanded=false] { + --backgroundColor: transparent; + --gutterSize: 0; + + display: flex; + align-items: center; + justify-content: flex-start; + padding: 5px 0; + + .mx_EventTile_avatar { + position: static; + order: -1; + margin-right: 5px; + } + + .mx_EventTile_line, + .mx_EventTile_info { + min-width: 100%; + // Preserve alignment with left edge of text in bubbles + margin: 0; + } + + .mx_EventTile_e2eIcon { + margin-left: 9px; + } + + .mx_EventTile_line > a { + // Align timestamps with those of normal bubble tiles + right: auto; + top: -11px; + left: -95px; + } +} + +.mx_EventListSummary[data-layout=bubble] { + --maxWidth: 70%; + margin-left: calc(var(--avatarSize) + var(--gutterSize)); + margin-right: 94px; + .mx_EventListSummary_toggle { + float: none; + margin: 0; + order: 9; + margin-left: 5px; + margin-right: 55px; + } + .mx_EventListSummary_avatars { + padding-top: 0; + } + + &::after { + content: ""; + clear: both; + } + + .mx_EventTile { + margin: 0 6px; + padding: 2px 0; + } + + .mx_EventTile_line { + margin: 0; + > a { + // Align timestamps with those of normal bubble tiles + left: -76px; + } + } + + .mx_MessageActionBar { + transform: translate3d(90%, 0, 0); + } +} + +.mx_EventListSummary[data-expanded=false][data-layout=bubble] { + // Align with left edge of bubble tiles + padding: 0 49px; +} + +/* events that do not require bubble layout */ +.mx_EventListSummary[data-layout=bubble], +.mx_EventTile.mx_EventTile_bad[data-layout=bubble] { + .mx_EventTile_line { + background: transparent; + } + + &:hover { + &::before { + background: transparent; + } + } +} diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 51d9e1cc9d..470851654b 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -1,6 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020-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. @@ -16,104 +16,311 @@ limitations under the License. */ $left-gutter: 64px; +$hover-select-border: 4px; -.mx_EventTile { +.mx_EventTile:not([data-layout=bubble]) { max-width: 100%; clear: both; padding-top: 18px; font-size: $font-14px; position: relative; -} -.mx_EventTile.mx_EventTile_info { - padding-top: 1px; -} + &.mx_EventTile_info { + padding-top: 1px; + } -.mx_EventTile_avatar { - top: 14px; - left: 8px; - cursor: pointer; - user-select: none; -} + .mx_EventTile_avatar { + top: 14px; + left: 8px; + cursor: pointer; + user-select: none; + } -.mx_EventTile.mx_EventTile_info .mx_EventTile_avatar { - top: $font-6px; - left: $left-gutter; -} + &.mx_EventTile_info .mx_EventTile_avatar { + top: $font-6px; + left: $left-gutter; + } -.mx_EventTile_continuation { - padding-top: 0px !important; + &.mx_EventTile_continuation { + padding-top: 0px !important; + + &.mx_EventTile_isEditing { + padding-top: 5px !important; + margin-top: -5px; + } + } &.mx_EventTile_isEditing { - padding-top: 5px !important; - margin-top: -5px; + background-color: $header-panel-bg-color; } -} -.mx_EventTile_isEditing { - background-color: $header-panel-bg-color; -} + .mx_SenderProfile { + color: $primary-content; + font-size: $font-14px; + display: inline-block; /* anti-zalgo, with overflow hidden */ + overflow: hidden; + padding-bottom: 0px; + padding-top: 0px; + margin: 0px; + /* the next three lines, along with overflow hidden, truncate long display names */ + white-space: nowrap; + text-overflow: ellipsis; + max-width: calc(100% - $left-gutter); + } -.mx_EventTile .mx_SenderProfile { - color: $primary-fg-color; - font-size: $font-14px; - display: inline-block; /* anti-zalgo, with overflow hidden */ - overflow: hidden; - cursor: pointer; - padding-bottom: 0px; - padding-top: 0px; - margin: 0px; - /* the next three lines, along with overflow hidden, truncate long display names */ - white-space: nowrap; - text-overflow: ellipsis; - max-width: calc(100% - $left-gutter); -} + .mx_SenderProfile .mx_Flair { + opacity: 0.7; + margin-left: 5px; + display: inline-block; + vertical-align: top; + overflow: hidden; + user-select: none; -.mx_EventTile .mx_SenderProfile .mx_Flair { - opacity: 0.7; - margin-left: 5px; - display: inline-block; - vertical-align: top; - overflow: hidden; - user-select: none; + img { + vertical-align: -2px; + margin-right: 2px; + border-radius: 8px; + } + } - img { - vertical-align: -2px; - margin-right: 2px; + &.mx_EventTile_isEditing .mx_MessageTimestamp { + visibility: hidden; + } + + .mx_MessageTimestamp { + display: block; + white-space: nowrap; + left: 0px; + text-align: center; + user-select: none; + } + + &.mx_EventTile_continuation .mx_EventTile_line { + clear: both; + } + + .mx_EventTile_line, .mx_EventTile_reply { + position: relative; + padding-left: $left-gutter; border-radius: 8px; } -} -.mx_EventTile_isEditing .mx_MessageTimestamp { - visibility: hidden !important; -} - -.mx_EventTile .mx_MessageTimestamp { - display: block; - visibility: hidden; - white-space: nowrap; - left: 0px; - text-align: center; - user-select: none; -} - -.mx_EventTile_continuation .mx_EventTile_line { - clear: both; -} - -.mx_EventTile_line, .mx_EventTile_reply { - position: relative; - padding-left: $left-gutter; - border-radius: 8px; -} - -.mx_RoomView_timeline_rr_enabled, -// on ELS we need the margin to allow interaction with the expand/collapse button which is normally in the RR gutter -.mx_EventListSummary { - .mx_EventTile_line { - /* ideally should be 100px, but 95px gives us a max thumbnail size of 800x600, which is nice */ - margin-right: 110px; + .mx_EventTile_reply { + margin-right: 10px; } + + &.mx_EventTile_selected > div > a > .mx_MessageTimestamp { + left: calc(-$hover-select-border); + } + + /* this is used for the tile for the event which is selected via the URL. + * TODO: ultimately we probably want some transition on here. + */ + &.mx_EventTile_selected > .mx_EventTile_line { + border-left: $accent-color 4px solid; + padding-left: calc($left-gutter - $hover-select-border); + background-color: $event-selected-color; + } + + &.mx_EventTile_highlight, + &.mx_EventTile_highlight .markdown-body { + color: $event-highlight-fg-color; + + .mx_EventTile_line { + background-color: $event-highlight-bg-color; + } + } + + &.mx_EventTile_selected.mx_EventTile_info .mx_EventTile_line { + padding-left: calc($left-gutter + 18px - $hover-select-border); + } + + &.mx_EventTile:hover .mx_EventTile_line, + &.mx_EventTile.mx_EventTile_actionBarFocused .mx_EventTile_line, + &.mx_EventTile.focus-visible:focus-within .mx_EventTile_line { + background-color: $event-selected-color; + } + + .mx_EventTile_searchHighlight { + background-color: $accent-color; + color: $accent-fg-color; + border-radius: 5px; + padding-left: 2px; + padding-right: 2px; + cursor: pointer; + } + + .mx_EventTile_searchHighlight a { + background-color: $accent-color; + color: $accent-fg-color; + } + + .mx_EventTile_receiptSent, + .mx_EventTile_receiptSending { + // We don't use `position: relative` on the element because then it won't line + // up with the other read receipts + + &::before { + background-color: $tertiary-content; + mask-repeat: no-repeat; + mask-position: center; + mask-size: 14px; + width: 14px; + height: 14px; + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + } + } + .mx_EventTile_receiptSent::before { + mask-image: url('$(res)/img/element-icons/circle-sent.svg'); + } + .mx_EventTile_receiptSending::before { + mask-image: url('$(res)/img/element-icons/circle-sending.svg'); + } + + &.mx_EventTile_contextual { + opacity: 0.4; + } + + .mx_EventTile_msgOption { + float: right; + text-align: right; + position: relative; + width: 90px; + + /* Hack to stop the height of this pushing the messages apart. + Replaces margin-top: -6px. This interacts better with a read + marker being in between. Content overflows. */ + height: 1px; + + margin-right: 10px; + } + + .mx_EventTile_msgOption a { + text-decoration: none; + } + + /* De-zalgoing */ + .mx_EventTile_body { + overflow-y: hidden; + } + + &:hover.mx_EventTile_verified .mx_EventTile_line, + &:hover.mx_EventTile_unverified .mx_EventTile_line, + &:hover.mx_EventTile_unknown .mx_EventTile_line { + padding-left: calc($left-gutter - $hover-select-border); + } + + &:hover.mx_EventTile_verified .mx_EventTile_line { + border-left: $e2e-verified-color $EventTile_e2e_state_indicator_width solid; + } + + &:hover.mx_EventTile_unverified .mx_EventTile_line { + border-left: $e2e-unverified-color $EventTile_e2e_state_indicator_width solid; + } + + &:hover.mx_EventTile_unknown .mx_EventTile_line { + border-left: $e2e-unknown-color $EventTile_e2e_state_indicator_width solid; + } + + &:hover.mx_EventTile_verified.mx_EventTile_info .mx_EventTile_line, + &:hover.mx_EventTile_unverified.mx_EventTile_info .mx_EventTile_line, + &:hover.mx_EventTile_unknown.mx_EventTile_info .mx_EventTile_line { + padding-left: calc($left-gutter + 18px - $hover-select-border); + } + + /* End to end encryption stuff */ + &:hover .mx_EventTile_e2eIcon { + opacity: 1; + } + + // Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) + &:hover.mx_EventTile_verified .mx_EventTile_line > a > .mx_MessageTimestamp, + &:hover.mx_EventTile_unverified .mx_EventTile_line > a > .mx_MessageTimestamp, + &:hover.mx_EventTile_unknown .mx_EventTile_line > a > .mx_MessageTimestamp { + left: calc(-$hover-select-border); + } + + // Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) + &:hover.mx_EventTile_verified .mx_EventTile_line > .mx_EventTile_e2eIcon, + &:hover.mx_EventTile_unverified .mx_EventTile_line > .mx_EventTile_e2eIcon, + &:hover.mx_EventTile_unknown .mx_EventTile_line > .mx_EventTile_e2eIcon { + display: block; + left: 41px; + } + + .mx_MImageBody { + margin-right: 34px; + } + + .mx_EventTile_e2eIcon { + position: absolute; + top: 6px; + left: 44px; + bottom: 0; + right: 0; + } + + .mx_ReactionsRow { + margin: 0; + padding: 4px 64px; + } +} + +.mx_EventTile:not([data-layout=bubble]).mx_EventTile_info .mx_EventTile_line, +.mx_EventListSummary:not([data-layout=bubble]) > :not(.mx_EventTile) .mx_EventTile_avatar ~ .mx_EventTile_line { + padding-left: calc($left-gutter + 18px); +} + +.mx_EventListSummary:not([data-layout=bubble]) .mx_EventTile_line { + padding-left: calc($left-gutter); +} + +/* all the overflow-y: hidden; are to trap Zalgos - + but they introduce an implicit overflow-x: auto. + so make that explicitly hidden too to avoid random + horizontal scrollbars occasionally appearing, like in + https://github.com/vector-im/vector-web/issues/1154 */ +.mx_EventTile_content { + overflow-y: hidden; + overflow-x: hidden; + margin-right: 34px; +} + +/* Spoiler stuff */ +.mx_EventTile_spoiler { + cursor: pointer; +} + +.mx_EventTile_spoiler_reason { + color: $event-timestamp-color; + font-size: $font-11px; +} + +.mx_EventTile_spoiler_content { + filter: blur(5px) saturate(0.1) sepia(1); + transition-duration: 0.5s; +} + +.mx_EventTile_spoiler.visible > .mx_EventTile_spoiler_content { + filter: none; +} + +.mx_RoomView_timeline_rr_enabled { + .mx_EventTile[data-layout=group] { + .mx_EventTile_line { + /* ideally should be 100px, but 95px gives us a max thumbnail size of 800x600, which is nice */ + margin-right: 110px; + } + } + // on ELS we need the margin to allow interaction with the expand/collapse button which is normally in the RR gutter +} + +.mx_SenderProfile { + cursor: pointer; } .mx_EventTile_bubbleContainer { @@ -130,138 +337,15 @@ $left-gutter: 64px; .mx_EventTile_msgOption { grid-column: 2; } -} -.mx_EventTile_reply { - margin-right: 10px; -} - -/* HACK to override line-height which is already marked important elsewhere */ -.mx_EventTile_bigEmoji.mx_EventTile_bigEmoji { - font-size: 48px !important; - line-height: 57px !important; -} - -.mx_MessagePanel_alwaysShowTimestamps .mx_MessageTimestamp { - visibility: visible; -} - -.mx_EventTile_selected > div > a > .mx_MessageTimestamp { - left: 3px; - width: auto; -} - -// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) -// The first set is to handle the 'group layout' (default) and the second for the IRC layout -.mx_EventTile_last > div > a > .mx_MessageTimestamp, -.mx_EventTile:hover > div > a > .mx_MessageTimestamp, -.mx_EventTile.mx_EventTile_actionBarFocused > div > a > .mx_MessageTimestamp, -.mx_EventTile.focus-visible:focus-within > div > a > .mx_MessageTimestamp, -.mx_IRCLayout .mx_EventTile_last > a > .mx_MessageTimestamp, -.mx_IRCLayout .mx_EventTile:hover > a > .mx_MessageTimestamp, -.mx_IRCLayout .mx_ReplyThread .mx_EventTile > a > .mx_MessageTimestamp, -.mx_IRCLayout .mx_EventTile.mx_EventTile_actionBarFocused > a > .mx_MessageTimestamp, -.mx_IRCLayout .mx_EventTile.focus-visible:focus-within > a > .mx_MessageTimestamp { - visibility: visible; -} - -.mx_EventTile:hover .mx_MessageActionBar, -.mx_EventTile.mx_EventTile_actionBarFocused .mx_MessageActionBar, -[data-whatinput='keyboard'] .mx_EventTile:focus-within .mx_MessageActionBar, -.mx_EventTile.focus-visible:focus-within .mx_MessageActionBar { - visibility: visible; -} - -/* this is used for the tile for the event which is selected via the URL. - * TODO: ultimately we probably want some transition on here. - */ -.mx_EventTile_selected > .mx_EventTile_line { - border-left: $accent-color 4px solid; - padding-left: 60px; - background-color: $event-selected-color; -} - -.mx_EventTile_highlight, -.mx_EventTile_highlight .markdown-body { - color: $event-highlight-fg-color; - - .mx_EventTile_line { - background-color: $event-highlight-bg-color; + &:hover { + .mx_EventTile_line { + // To avoid bubble events being highlighted + background-color: inherit !important; + } } } -.mx_EventTile_selected.mx_EventTile_info .mx_EventTile_line { - padding-left: 78px; -} - -.mx_EventTile:hover .mx_EventTile_line, -.mx_EventTile.mx_EventTile_actionBarFocused .mx_EventTile_line, -.mx_EventTile.focus-visible:focus-within .mx_EventTile_line { - background-color: $event-selected-color; -} - -.mx_EventTile_searchHighlight { - background-color: $accent-color; - color: $accent-fg-color; - border-radius: 5px; - padding-left: 2px; - padding-right: 2px; - cursor: pointer; -} - -.mx_EventTile_searchHighlight a { - background-color: $accent-color; - color: $accent-fg-color; -} - -.mx_EventTile_receiptSent, -.mx_EventTile_receiptSending { - // We don't use `position: relative` on the element because then it won't line - // up with the other read receipts - - &::before { - background-color: $tertiary-fg-color; - mask-repeat: no-repeat; - mask-position: center; - mask-size: 14px; - width: 14px; - height: 14px; - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - } -} -.mx_EventTile_receiptSent::before { - mask-image: url('$(res)/img/element-icons/circle-sent.svg'); -} -.mx_EventTile_receiptSending::before { - mask-image: url('$(res)/img/element-icons/circle-sending.svg'); -} - -.mx_EventTile_contextual { - opacity: 0.4; -} - -.mx_EventTile_msgOption { - float: right; - text-align: right; - position: relative; - width: 90px; - - /* Hack to stop the height of this pushing the messages apart. - Replaces margin-top: -6px. This interacts better with a read - marker being in between. Content overflows. */ - height: 1px; - - margin-right: 10px; -} - -.mx_EventTile_msgOption a { - text-decoration: none; -} - .mx_EventTile_readAvatars { position: relative; display: inline-block; @@ -292,52 +376,27 @@ $left-gutter: 64px; position: absolute; } -/* all the overflow-y: hidden; are to trap Zalgos - - but they introduce an implicit overflow-x: auto. - so make that explicitly hidden too to avoid random - horizontal scrollbars occasionally appearing, like in - https://github.com/vector-im/vector-web/issues/1154 - */ -.mx_EventTile_content { - display: block; - overflow-y: hidden; - overflow-x: hidden; - margin-right: 34px; +/* HACK to override line-height which is already marked important elsewhere */ +.mx_EventTile_bigEmoji.mx_EventTile_bigEmoji { + font-size: 48px !important; + line-height: 57px !important; } -/* De-zalgoing */ -.mx_EventTile_body { - overflow-y: hidden; -} - -/* Spoiler stuff */ -.mx_EventTile_spoiler { +.mx_EventTile_content .mx_EventTile_edited { + user-select: none; + font-size: $font-12px; + color: $roomtopic-color; + display: inline-block; + margin-left: 9px; cursor: pointer; } -.mx_EventTile_spoiler_reason { - color: $event-timestamp-color; - font-size: $font-11px; -} - -.mx_EventTile_spoiler_content { - filter: blur(5px) saturate(0.1) sepia(1); - transition-duration: 0.5s; -} - -.mx_EventTile_spoiler.visible > .mx_EventTile_spoiler_content { - filter: none; -} .mx_EventTile_e2eIcon { - position: absolute; - top: 6px; - left: 44px; + position: relative; width: 14px; height: 14px; display: block; - bottom: 0; - right: 0; opacity: 0.2; background-repeat: no-repeat; background-size: contain; @@ -360,7 +419,7 @@ $left-gutter: 64px; mask-image: url('$(res)/img/e2e/normal.svg'); mask-repeat: no-repeat; mask-position: center; - mask-size: 90%; + mask-size: 80%; } } @@ -396,91 +455,16 @@ $left-gutter: 64px; opacity: 1; } -.mx_EventTile_keyRequestInfo { - font-size: $font-12px; -} - -.mx_EventTile_keyRequestInfo_text { - opacity: 0.5; -} - -.mx_EventTile_keyRequestInfo_text a { - color: $primary-fg-color; - text-decoration: underline; - cursor: pointer; -} - -.mx_EventTile_keyRequestInfo_tooltip_contents p { - text-align: auto; - margin-left: 3px; - margin-right: 3px; -} - -.mx_EventTile_keyRequestInfo_tooltip_contents p:first-child { - margin-top: 0px; -} - -.mx_EventTile_keyRequestInfo_tooltip_contents p:last-child { - margin-bottom: 0px; -} - -.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line, -.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line, -.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line { - padding-left: 60px; -} - -.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line { - border-left: $e2e-verified-color $EventTile_e2e_state_indicator_width solid; -} - -.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line { - border-left: $e2e-unverified-color $EventTile_e2e_state_indicator_width solid; -} - -.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line { - border-left: $e2e-unknown-color $EventTile_e2e_state_indicator_width solid; -} - -.mx_EventTile:hover.mx_EventTile_verified.mx_EventTile_info .mx_EventTile_line, -.mx_EventTile:hover.mx_EventTile_unverified.mx_EventTile_info .mx_EventTile_line, -.mx_EventTile:hover.mx_EventTile_unknown.mx_EventTile_info .mx_EventTile_line { - padding-left: 78px; -} - -/* End to end encryption stuff */ -.mx_EventTile:hover .mx_EventTile_e2eIcon { - opacity: 1; -} - -// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) -.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > a > .mx_MessageTimestamp, -.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > a > .mx_MessageTimestamp, -.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line > a > .mx_MessageTimestamp { - width: $MessageTimestamp_width_hover; -} - -// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) -.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > .mx_EventTile_e2eIcon, -.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > .mx_EventTile_e2eIcon, -.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line > .mx_EventTile_e2eIcon { - display: block; - left: 41px; -} - -.mx_EventTile_content .mx_EventTile_edited { - user-select: none; - font-size: $font-12px; - color: $roomtopic-color; - display: inline-block; - margin-left: 9px; - cursor: pointer; -} - /* Various markdown overrides */ -.mx_EventTile_body pre { - border: 1px solid transparent; +.mx_EventTile_body { + a:hover { + text-decoration: underline; + } + + pre { + border: 1px solid transparent; + } } .mx_EventTile_content .markdown-body { @@ -492,8 +476,11 @@ $left-gutter: 64px; pre, code { font-family: $monospace-font-family !important; - // deliberate constants as we're behind an invert filter - color: #333; + background-color: $header-panel-bg-color; + } + + pre code > * { + display: inline; } pre { @@ -502,11 +489,10 @@ $left-gutter: 64px; // https://github.com/vector-im/vector-web/issues/754 overflow-x: overlay; overflow-y: visible; - } - code { - // deliberate constants as we're behind an invert filter - background-color: #f8f8f8; + &::-webkit-scrollbar-corner { + background: transparent; + } } } @@ -528,7 +514,7 @@ $left-gutter: 64px; .mx_EventTile:hover .mx_EventTile_body pre, .mx_EventTile.focus-visible:focus-within .mx_EventTile_body pre { - border: 1px solid #e5e5e5; // deliberate constant as we're behind an invert filter + border: 1px solid $tertiary-content; } .mx_EventTile_pre_container { @@ -598,6 +584,12 @@ $left-gutter: 64px; color: $accent-color-alt; } +.mx_EventTile_content .markdown-body blockquote { + border-left: 2px solid $blockquote-bar-color; + border-radius: 2px; + padding: 0 10px; +} + .mx_EventTile_content .markdown-body .hljs { display: inline !important; } @@ -616,12 +608,42 @@ $left-gutter: 64px; /* end of overrides */ + +.mx_EventTile_keyRequestInfo { + font-size: $font-12px; +} + +.mx_EventTile_keyRequestInfo_text { + opacity: 0.5; +} + +.mx_EventTile_keyRequestInfo_text a { + color: $primary-content; + text-decoration: underline; + cursor: pointer; +} + +.mx_EventTile_keyRequestInfo_tooltip_contents p { + text-align: auto; + margin-left: 3px; + margin-right: 3px; +} + +.mx_EventTile_keyRequestInfo_tooltip_contents p:first-child { + margin-top: 0px; +} + +.mx_EventTile_keyRequestInfo_tooltip_contents p:last-child { + margin-bottom: 0px; +} + .mx_EventTile_tileError { color: red; text-align: center; // Remove some of the default tile padding so that the error is centered margin-right: 0; + .mx_EventTile_line { padding-left: 0; margin-right: 0; @@ -636,6 +658,13 @@ $left-gutter: 64px; } } +.mx_EventTile:hover .mx_MessageActionBar, +.mx_EventTile.mx_EventTile_actionBarFocused .mx_MessageActionBar, +[data-whatinput='keyboard'] .mx_EventTile:focus-within .mx_MessageActionBar, +.mx_EventTile.focus-visible:focus-within .mx_MessageActionBar { + visibility: visible; +} + @media only screen and (max-width: 480px) { .mx_EventTile_line, .mx_EventTile_reply { padding-left: 0; @@ -646,3 +675,71 @@ $left-gutter: 64px; margin-right: 0; } } + +.mx_ThreadInfo:hover { + cursor: pointer; +} + +.mx_ThreadView { + display: flex; + flex-direction: column; + + .mx_ScrollPanel { + margin-top: 20px; + + .mx_RoomView_MessageList { + padding: 0; + } + } + + .mx_EventTile_senderDetails { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; + + a { + flex: 1; + min-width: none; + max-width: 100%; + display: flex; + align-items: center; + + .mx_SenderProfile { + flex: 1; + } + } + } + + .mx_ThreadView_List { + flex: 1; + overflow: scroll; + } + + .mx_EventTile_roomName { + display: none; + } + + .mx_EventTile_line { + padding-left: 0 !important; + order: 10 !important; + } + + .mx_EventTile { + width: 100%; + display: flex; + flex-direction: column; + margin-top: 0; + padding-bottom: 5px; + margin-bottom: 5px; + + .mx_MessageTimestamp { + left: auto; + right: 0; + } + } + + .mx_MessageComposer_sendMessage { + margin-right: 0; + } +} diff --git a/res/css/views/rooms/_GroupLayout.scss b/res/css/views/rooms/_GroupLayout.scss index 818509785b..ebb7f99e45 100644 --- a/res/css/views/rooms/_GroupLayout.scss +++ b/res/css/views/rooms/_GroupLayout.scss @@ -24,12 +24,9 @@ $left-gutter: 64px; margin-left: $left-gutter; } - > .mx_EventTile_line { - padding-left: $left-gutter; - } - > .mx_EventTile_avatar { position: absolute; + z-index: 9; } .mx_MessageTimestamp { @@ -43,10 +40,6 @@ $left-gutter: 64px; line-height: $font-22px; } } - - .mx_EventTile_info .mx_EventTile_line { - padding-left: calc($left-gutter + 18px); - } } /* Compact layout overrides */ diff --git a/res/css/views/rooms/_IRCLayout.scss b/res/css/views/rooms/_IRCLayout.scss index cf61ce569d..578c0325d2 100644 --- a/res/css/views/rooms/_IRCLayout.scss +++ b/res/css/views/rooms/_IRCLayout.scss @@ -29,6 +29,7 @@ $irc-line-height: $font-18px; // timestamps are links which shouldn't be underlined > a { text-decoration: none; + min-width: 45px; } display: flex; @@ -49,18 +50,6 @@ $irc-line-height: $font-18px; } } - > .mx_SenderProfile { - order: 2; - flex-shrink: 0; - width: var(--name-width); - text-overflow: ellipsis; - text-align: left; - display: flex; - align-items: center; - overflow: visible; - justify-content: flex-end; - } - .mx_EventTile_line, .mx_EventTile_reply { padding: 0; display: flex; @@ -127,6 +116,11 @@ $irc-line-height: $font-18px; .mx_EditMessageComposer_buttons { position: relative; } + + .mx_ReactionsRow { + padding-left: 0; + padding-right: 0; + } } .mx_EventTile_emote { @@ -173,44 +167,46 @@ $irc-line-height: $font-18px; border-left: 0; } - .mx_SenderProfile_hover { - background-color: $primary-bg-color; - overflow: hidden; + .mx_SenderProfile { + width: var(--name-width); display: flex; + order: 2; + flex-shrink: 0; + justify-content: flex-start; + align-items: center; - > .mx_SenderProfile_name { + > .mx_SenderProfile_displayName { + width: 100%; + text-align: end; overflow: hidden; text-overflow: ellipsis; - min-width: var(--name-width); - text-align: end; + } + + > .mx_SenderProfile_mxid { + visibility: collapse; } } .mx_SenderProfile:hover { - justify-content: flex-start; - } - - .mx_SenderProfile_hover:hover { overflow: visible; - width: max(auto, 100%); z-index: 10; + + > .mx_SenderProfile_displayName { + overflow: visible; + } + + > .mx_SenderProfile_mxid { + visibility: visible; + } } .mx_ReplyThread { margin: 0; .mx_SenderProfile { + order: unset; + max-width: unset; width: unset; - max-width: var(--name-width); - } - - .mx_SenderProfile_hover { background: transparent; - - > span { - > .mx_SenderProfile_name { - min-width: inherit; - } - } } .mx_EventTile_emote { diff --git a/res/css/views/rooms/_JumpToBottomButton.scss b/res/css/views/rooms/_JumpToBottomButton.scss index a8dc2ce11c..2b38b509de 100644 --- a/res/css/views/rooms/_JumpToBottomButton.scss +++ b/res/css/views/rooms/_JumpToBottomButton.scss @@ -56,7 +56,7 @@ limitations under the License. height: 38px; border-radius: 19px; box-sizing: border-box; - background: $primary-bg-color; + background: $background; border: 1.3px solid $muted-fg-color; cursor: pointer; } diff --git a/res/css/views/rooms/_LinkPreviewGroup.scss b/res/css/views/rooms/_LinkPreviewGroup.scss new file mode 100644 index 0000000000..ed341904fd --- /dev/null +++ b/res/css/views/rooms/_LinkPreviewGroup.scss @@ -0,0 +1,38 @@ +/* +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. +*/ + +.mx_LinkPreviewGroup { + .mx_LinkPreviewGroup_hide { + cursor: pointer; + width: 18px; + height: 18px; + + img { + flex: 0 0 40px; + visibility: hidden; + } + } + + &:hover .mx_LinkPreviewGroup_hide img, + .mx_LinkPreviewGroup_hide.focus-visible:focus img { + visibility: visible; + } + + > .mx_AccessibleButton { + color: $accent-color; + text-align: center; + } +} diff --git a/res/css/views/rooms/_LinkPreviewWidget.scss b/res/css/views/rooms/_LinkPreviewWidget.scss index 022cf3ed28..24900ee14b 100644 --- a/res/css/views/rooms/_LinkPreviewWidget.scss +++ b/res/css/views/rooms/_LinkPreviewWidget.scss @@ -19,7 +19,8 @@ limitations under the License. margin-right: 15px; margin-bottom: 15px; display: flex; - border-left: 4px solid $preview-widget-bar-color; + border-left: 2px solid $preview-widget-bar-color; + border-radius: 2px; color: $preview-widget-fg-color; } @@ -33,38 +34,29 @@ limitations under the License. .mx_LinkPreviewWidget_caption { margin-left: 15px; flex: 1 1 auto; + overflow: hidden; // cause it to wrap rather than clip } .mx_LinkPreviewWidget_title { - display: inline; font-weight: bold; white-space: normal; -} + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; -.mx_LinkPreviewWidget_siteName { - display: inline; + .mx_LinkPreviewWidget_siteName { + font-weight: normal; + } } .mx_LinkPreviewWidget_description { margin-top: 8px; white-space: normal; word-wrap: break-word; -} - -.mx_LinkPreviewWidget_cancel { - cursor: pointer; - width: 18px; - height: 18px; - - img { - flex: 0 0 40px; - visibility: hidden; - } -} - -.mx_LinkPreviewWidget:hover .mx_LinkPreviewWidget_cancel img, -.mx_LinkPreviewWidget_cancel.focus-visible:focus img { - visibility: visible; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; } .mx_MatrixChat_useCompactLayout { diff --git a/res/css/views/rooms/_MemberInfo.scss b/res/css/views/rooms/_MemberInfo.scss index 3f7f83d334..4abd9c7c30 100644 --- a/res/css/views/rooms/_MemberInfo.scss +++ b/res/css/views/rooms/_MemberInfo.scss @@ -111,7 +111,7 @@ limitations under the License. .mx_MemberInfo_field { cursor: pointer; font-size: $font-15px; - color: $primary-fg-color; + color: $primary-content; margin-left: 8px; line-height: $font-23px; } diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index e6c0cc3f46..c20dd43daf 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -130,7 +130,7 @@ limitations under the License. @keyframes visualbell { from { background-color: $visual-bell-bg-color; } - to { background-color: $primary-bg-color; } + to { background-color: $background; } } .mx_MessageComposer_input_error { @@ -160,13 +160,11 @@ limitations under the License. resize: none; outline: none; box-shadow: none; - color: $primary-fg-color; - background-color: $primary-bg-color; + color: $primary-content; + background-color: $background; font-size: $font-14px; max-height: 120px; overflow: auto; - /* needed for FF */ - font-family: $font-family; } /* hack for FF as vertical alignment of custom placeholder text is broken */ @@ -187,13 +185,26 @@ limitations under the License. } } +.mx_ContextualMenu { + .mx_MessageComposer_button { + padding-left: calc(var(--size) + 6px); + } +} + .mx_MessageComposer_button { + --size: 26px; position: relative; - margin-right: 6px; cursor: pointer; - height: 26px; - width: 26px; + height: var(--size); + line-height: var(--size); + width: auto; + padding-left: var(--size); border-radius: 100%; + margin-right: 6px; + + &:last-child { + margin-right: auto; + } &::before { content: ''; @@ -209,8 +220,22 @@ limitations under the License. mask-position: center; } - &:hover { - background: rgba($accent-color, 0.1); + &::after { + content: ''; + position: absolute; + left: 0; + top: 0; + z-index: 0; + width: var(--size); + height: var(--size); + border-radius: 50%; + } + + &:hover, + &.mx_MessageComposer_closeButtonMenu { + &::after { + background: rgba($accent-color, 0.1); + } &::before { background-color: $accent-color; @@ -239,10 +264,18 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/room/composer/sticker.svg'); } +.mx_MessageComposer_buttonMenu::before { + mask-image: url('$(res)/img/image-view/more.svg'); +} + +.mx_MessageComposer_closeButtonMenu::before { + transform: rotate(90deg); + transform-origin: center; +} + .mx_MessageComposer_sendMessage { cursor: pointer; position: relative; - margin-right: 6px; width: 32px; height: 32px; border-radius: 100%; @@ -342,3 +375,28 @@ limitations under the License. height: 50px; } } + +/** + * Unstable compact mode + */ + +.mx_MessageComposer.mx_MessageComposer--compact { + margin-right: 0; + + .mx_MessageComposer_wrapper { + padding: 0 0 0 25px; + } + + .mx_MessageComposer_button:last-child { + margin-right: 0; + } + + .mx_MessageComposer_e2eIcon { + left: 0; + } +} + +.mx_MessageComposer_Menu .mx_CallContextMenu_item { + display: flex; + align-items: center; +} diff --git a/res/css/views/rooms/_NewRoomIntro.scss b/res/css/views/rooms/_NewRoomIntro.scss index e0cccfa885..f0e471d384 100644 --- a/res/css/views/rooms/_NewRoomIntro.scss +++ b/res/css/views/rooms/_NewRoomIntro.scss @@ -67,6 +67,6 @@ limitations under the License. > p { margin: 0; font-size: $font-15px; - color: $secondary-fg-color; + color: $secondary-content; } } diff --git a/res/css/views/rooms/_NotificationBadge.scss b/res/css/views/rooms/_NotificationBadge.scss index 64b2623238..670e057cfa 100644 --- a/res/css/views/rooms/_NotificationBadge.scss +++ b/res/css/views/rooms/_NotificationBadge.scss @@ -42,7 +42,7 @@ limitations under the License. // These are the 3 background types &.mx_NotificationBadge_dot { - background-color: $primary-fg-color; // increased visibility + background-color: $primary-content; // increased visibility width: 6px; height: 6px; diff --git a/res/css/views/rooms/_PinnedEventTile.scss b/res/css/views/rooms/_PinnedEventTile.scss index 15b3c16faa..07978a8f65 100644 --- a/res/css/views/rooms/_PinnedEventTile.scss +++ b/res/css/views/rooms/_PinnedEventTile.scss @@ -67,7 +67,7 @@ limitations under the License. //left: 0; height: inherit; width: inherit; - background: $secondary-fg-color; + background: $secondary-content; mask-position: center; mask-size: 8px; mask-repeat: no-repeat; @@ -87,7 +87,7 @@ limitations under the License. .mx_PinnedEventTile_timestamp { font-size: inherit; line-height: inherit; - color: $secondary-fg-color; + color: $secondary-content; } .mx_AccessibleButton_kind_link { diff --git a/res/css/views/rooms/_ReplyPreview.scss b/res/css/views/rooms/_ReplyPreview.scss index 10f8e21e43..70a820e412 100644 --- a/res/css/views/rooms/_ReplyPreview.scss +++ b/res/css/views/rooms/_ReplyPreview.scss @@ -16,34 +16,40 @@ limitations under the License. .mx_ReplyPreview { border: 1px solid $primary-hairline-color; - background: $primary-bg-color; + background: $background; border-bottom: none; border-radius: 8px 8px 0 0; max-height: 50vh; overflow: auto; box-shadow: 0px -16px 32px $composer-shadow-color; + + .mx_ReplyPreview_section { + border-bottom: 1px solid $primary-hairline-color; + + .mx_ReplyPreview_header { + margin: 8px; + color: $primary-content; + font-weight: 400; + opacity: 0.4; + } + + .mx_ReplyPreview_tile { + margin: 0 8px; + } + + .mx_ReplyPreview_title { + float: left; + } + + .mx_ReplyPreview_cancel { + float: right; + cursor: pointer; + display: flex; + } + + .mx_ReplyPreview_clear { + clear: both; + } + } } -.mx_ReplyPreview_section { - border-bottom: 1px solid $primary-hairline-color; -} - -.mx_ReplyPreview_header { - margin: 12px; - color: $primary-fg-color; - font-weight: 400; - opacity: 0.4; -} - -.mx_ReplyPreview_title { - float: left; -} - -.mx_ReplyPreview_cancel { - float: right; - cursor: pointer; -} - -.mx_ReplyPreview_clear { - clear: both; -} diff --git a/res/css/views/rooms/_ReplyTile.scss b/res/css/views/rooms/_ReplyTile.scss new file mode 100644 index 0000000000..3ef6491ec9 --- /dev/null +++ b/res/css/views/rooms/_ReplyTile.scss @@ -0,0 +1,117 @@ +/* +Copyright 2020 Tulir Asokan + +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_ReplyTile { + position: relative; + padding: 2px 0; + font-size: $font-14px; + line-height: $font-16px; + + &.mx_ReplyTile_audio .mx_MFileBody_info_icon::before { + mask-image: url("$(res)/img/element-icons/speaker.svg"); + } + + &.mx_ReplyTile_video .mx_MFileBody_info_icon::before { + mask-image: url("$(res)/img/element-icons/call/video-call.svg"); + } + + .mx_MFileBody { + .mx_MFileBody_info { + margin: 5px 0; + } + + .mx_MFileBody_download { + display: none; + } + } + + > a { + display: flex; + flex-direction: column; + text-decoration: none; + color: $primary-content; + } + + .mx_RedactedBody { + padding: 4px 0 2px 20px; + + &::before { + height: 13px; + width: 13px; + top: 5px; + } + } + + // We do reply size limiting with CSS to avoid duplicating the TextualBody component. + .mx_EventTile_content { + $reply-lines: 2; + $line-height: $font-22px; + + text-overflow: ellipsis; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: $reply-lines; + line-height: $line-height; + + .mx_EventTile_body.mx_EventTile_bigEmoji { + line-height: $line-height !important; + font-size: $font-14px !important; // Override the big emoji override + } + + // Hide line numbers + .mx_EventTile_lineNumbers { + display: none; + } + + // Hack to cut content in
 tags too
+        .mx_EventTile_pre_container > pre {
+            overflow: hidden;
+            text-overflow: ellipsis;
+            display: -webkit-box;
+            -webkit-box-orient: vertical;
+            -webkit-line-clamp: $reply-lines;
+            padding: 4px;
+        }
+
+        .markdown-body blockquote,
+        .markdown-body dl,
+        .markdown-body ol,
+        .markdown-body p,
+        .markdown-body pre,
+        .markdown-body table,
+        .markdown-body ul {
+            margin-bottom: 4px;
+        }
+    }
+
+    &.mx_ReplyTile_info {
+        padding-top: 0;
+    }
+
+    .mx_SenderProfile {
+        font-size: $font-14px;
+        line-height: $font-17px;
+
+        display: inline-block; // anti-zalgo, with overflow hidden
+        padding: 0;
+        margin: 0;
+
+        // truncate long display names
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+    }
+}
diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss
index 4142b0a2ef..81dfa90c96 100644
--- a/res/css/views/rooms/_RoomHeader.scss
+++ b/res/css/views/rooms/_RoomHeader.scss
@@ -17,7 +17,7 @@ limitations under the License.
 .mx_RoomHeader {
     flex: 0 0 50px;
     border-bottom: 1px solid $primary-hairline-color;
-    background-color: $roomheader-bg-color;
+    background-color: $background;
 
     .mx_RoomHeader_e2eIcon {
         height: 12px;
@@ -74,7 +74,7 @@ limitations under the License.
 
 .mx_RoomHeader_buttons {
     display: flex;
-    background-color: $primary-bg-color;
+    background-color: $background;
 }
 
 .mx_RoomHeader_info {
diff --git a/res/css/views/rooms/_RoomList.scss b/res/css/views/rooms/_RoomList.scss
index 8eda25d0c9..7d967661a6 100644
--- a/res/css/views/rooms/_RoomList.scss
+++ b/res/css/views/rooms/_RoomList.scss
@@ -43,11 +43,11 @@ limitations under the License.
     div:first-child {
         font-weight: $font-semi-bold;
         line-height: $font-18px;
-        color: $primary-fg-color;
+        color: $primary-content;
     }
 
     .mx_AccessibleButton {
-        color: $primary-fg-color;
+        color: $primary-content;
         position: relative;
         padding: 8px 8px 8px 32px;
         font-size: inherit;
@@ -64,7 +64,7 @@ limitations under the License.
             position: absolute;
             top: 8px;
             left: 8px;
-            background: $secondary-fg-color;
+            background: $secondary-content;
             mask-position: center;
             mask-size: contain;
             mask-repeat: no-repeat;
diff --git a/res/css/views/rooms/_RoomSublist.scss b/res/css/views/rooms/_RoomSublist.scss
index 146b3edf71..6db2185dd5 100644
--- a/res/css/views/rooms/_RoomSublist.scss
+++ b/res/css/views/rooms/_RoomSublist.scss
@@ -172,14 +172,12 @@ limitations under the License.
         }
     }
 
-    // In the general case, we leave height of headers alone even if sticky, so
-    // that the sublists below them do not jump. However, that leaves a gap
-    // when scrolled to the top above the first sublist (whose header can only
-    // ever stick to top), so we force height to 0 for only that first header.
-    // See also https://github.com/vector-im/element-web/issues/14429.
-    &:first-child .mx_RoomSublist_headerContainer {
-        height: 0;
-        padding-bottom: 4px;
+    // In the general case, we reserve space for each sublist header to prevent
+    // scroll jumps when they become sticky. However, that leaves a gap when
+    // scrolled to the top above the first sublist (whose header can only ever
+    // stick to top), so we make sure to exclude the first visible sublist.
+    &:not(.mx_RoomSublist_hidden) ~ .mx_RoomSublist .mx_RoomSublist_headerContainer {
+        height: 24px;
     }
 
     .mx_RoomSublist_resizeBox {
@@ -233,7 +231,7 @@ limitations under the License.
         &:hover, &.mx_RoomSublist_hasMenuOpen {
             .mx_RoomSublist_resizerHandle {
                 opacity: 0.8;
-                background-color: $primary-fg-color;
+                background-color: $primary-content;
             }
         }
     }
diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss
index 03146e0325..0c04f27115 100644
--- a/res/css/views/rooms/_RoomTile.scss
+++ b/res/css/views/rooms/_RoomTile.scss
@@ -124,7 +124,7 @@ limitations under the License.
             mask-position: center;
             mask-size: contain;
             mask-repeat: no-repeat;
-            background: $primary-fg-color;
+            background: $primary-content;
         }
     }
 
@@ -193,6 +193,10 @@ limitations under the License.
         mask-image: url('$(res)/img/element-icons/settings.svg');
     }
 
+    .mx_RoomTile_iconCopyLink::before {
+        mask-image: url('$(res)/img/element-icons/link.svg');
+    }
+
     .mx_RoomTile_iconInvite::before {
         mask-image: url('$(res)/img/element-icons/room/invite.svg');
     }
diff --git a/res/css/views/rooms/_SearchBar.scss b/res/css/views/rooms/_SearchBar.scss
index d9f730a8b6..e08168a122 100644
--- a/res/css/views/rooms/_SearchBar.scss
+++ b/res/css/views/rooms/_SearchBar.scss
@@ -47,7 +47,7 @@ limitations under the License.
         padding: 5px;
         font-size: $font-15px;
         cursor: pointer;
-        color: $primary-fg-color;
+        color: $primary-content;
         border-bottom: 2px solid $accent-color;
         font-weight: 600;
     }
diff --git a/res/css/views/rooms/_SendMessageComposer.scss b/res/css/views/rooms/_SendMessageComposer.scss
index 9f6a8d52ce..4b7eb54188 100644
--- a/res/css/views/rooms/_SendMessageComposer.scss
+++ b/res/css/views/rooms/_SendMessageComposer.scss
@@ -29,8 +29,10 @@ limitations under the License.
         display: flex;
         flex-direction: column;
         // min-height at this level so the mx_BasicMessageComposer_input
-        // still stays vertically centered when less than 50px
-        min-height: 50px;
+        // still stays vertically centered when less than 55px.
+        // We also set this to ensure the voice message recording widget
+        // doesn't cause a jump.
+        min-height: 55px;
 
         .mx_BasicMessageComposer_input {
             padding: 3px 0;
diff --git a/res/css/views/rooms/_TopUnreadMessagesBar.scss b/res/css/views/rooms/_TopUnreadMessagesBar.scss
index 8841b042a0..7c7d96e713 100644
--- a/res/css/views/rooms/_TopUnreadMessagesBar.scss
+++ b/res/css/views/rooms/_TopUnreadMessagesBar.scss
@@ -41,7 +41,7 @@ limitations under the License.
     height: 38px;
     border-radius: 19px;
     box-sizing: border-box;
-    background: $primary-bg-color;
+    background: $background;
     border: 1.3px solid $muted-fg-color;
     cursor: pointer;
 }
@@ -62,7 +62,7 @@ limitations under the License.
     display: block;
     width: 18px;
     height: 18px;
-    background: $primary-bg-color;
+    background: $background;
     border: 1.3px solid $muted-fg-color;
     border-radius: 10px;
     margin: 5px auto;
diff --git a/res/css/views/rooms/_VoiceRecordComposerTile.scss b/res/css/views/rooms/_VoiceRecordComposerTile.scss
index a3ee104bd8..69fe292c0a 100644
--- a/res/css/views/rooms/_VoiceRecordComposerTile.scss
+++ b/res/css/views/rooms/_VoiceRecordComposerTile.scss
@@ -20,7 +20,7 @@ limitations under the License.
     height: 28px;
     border: 2px solid $voice-record-stop-border-color;
     border-radius: 32px;
-    margin-right: 16px; // between us and the send button
+    margin-right: 8px; // between us and the waveform component
     position: relative;
 
     &::after {
@@ -36,19 +36,38 @@ limitations under the License.
 }
 
 .mx_VoiceRecordComposerTile_delete {
-    width: 14px; // w&h are size of icon
-    height: 18px;
+    width: 24px;
+    height: 24px;
     vertical-align: middle;
-    margin-right: 11px; // distance from left edge of waveform container (container has some margin too)
+    margin-right: 8px; // distance from left edge of waveform container (container has some margin too)
     background-color: $voice-record-icon-color;
     mask-repeat: no-repeat;
     mask-size: contain;
     mask-image: url('$(res)/img/element-icons/trashcan.svg');
 }
 
+.mx_VoiceRecordComposerTile_uploadingState {
+    margin-right: 10px;
+    color: $secondary-content;
+}
+
+.mx_VoiceRecordComposerTile_failedState {
+    margin-right: 21px;
+
+    .mx_VoiceRecordComposerTile_uploadState_badge {
+        display: inline-block;
+        margin-right: 4px;
+        vertical-align: middle;
+    }
+}
+
 .mx_MessageComposer_row .mx_VoiceMessagePrimaryContainer {
     // Note: remaining class properties are in the PlayerContainer CSS.
 
+    // fixed height to reduce layout jumps with the play button appearing
+    // https://github.com/vector-im/element-web/issues/18431
+    height: 32px;
+
     margin: 6px; // force the composer area to put a gutter around us
     margin-right: 12px; // isolate from stop/send button
 
@@ -68,7 +87,7 @@ limitations under the License.
             height: 10px;
             position: absolute;
             left: 12px; // 12px from the left edge for container padding
-            top: 18px; // vertically center (middle align with clock)
+            top: 17px; // vertically center (middle align with clock)
             border-radius: 10px;
         }
     }
diff --git a/res/css/views/rooms/_WhoIsTypingTile.scss b/res/css/views/rooms/_WhoIsTypingTile.scss
index 1c0dabbeb5..49655742bb 100644
--- a/res/css/views/rooms/_WhoIsTypingTile.scss
+++ b/res/css/views/rooms/_WhoIsTypingTile.scss
@@ -36,7 +36,7 @@ limitations under the License.
 }
 
 .mx_WhoIsTypingTile_avatars .mx_BaseAvatar {
-    border: 1px solid $primary-bg-color;
+    border: 1px solid $background;
     border-radius: 40px;
 }
 
@@ -45,7 +45,7 @@ limitations under the License.
     display: inline-block;
     color: #acacac;
     background-color: #ddd;
-    border: 1px solid $primary-bg-color;
+    border: 1px solid $background;
     border-radius: 40px;
     width: 24px;
     height: 24px;
diff --git a/res/css/views/settings/_JoinRuleSettings.scss b/res/css/views/settings/_JoinRuleSettings.scss
new file mode 100644
index 0000000000..8b520b2ab1
--- /dev/null
+++ b/res/css/views/settings/_JoinRuleSettings.scss
@@ -0,0 +1,88 @@
+/*
+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.
+*/
+
+.mx_JoinRuleSettings_upgradeRequired {
+    margin-left: 16px;
+    padding: 4px 16px;
+    border: 1px solid $accent-color;
+    border-radius: 8px;
+    color: $accent-color;
+    font-size: $font-12px;
+    line-height: $font-15px;
+}
+
+.mx_JoinRuleSettings_spacesWithAccess {
+    > h4 {
+        color: $secondary-content;
+        font-weight: $font-semi-bold;
+        font-size: $font-12px;
+        line-height: $font-15px;
+        text-transform: uppercase;
+    }
+
+    > span {
+        font-weight: 500;
+        font-size: $font-14px;
+        line-height: 32px; // matches height of avatar for v-align
+        color: $secondary-content;
+        display: inline-block;
+
+        img.mx_RoomAvatar_isSpaceRoom,
+        .mx_RoomAvatar_isSpaceRoom img {
+            border-radius: 8px;
+        }
+
+        .mx_BaseAvatar {
+            margin-right: 8px;
+        }
+
+        & + span {
+            margin-left: 16px;
+        }
+    }
+}
+
+.mx_JoinRuleSettings_radioButton {
+    padding-top: 16px;
+    margin-bottom: 8px;
+
+    .mx_RadioButton_content {
+        margin-left: 14px;
+        font-weight: $font-semi-bold;
+        font-size: $font-15px;
+        line-height: $font-24px;
+        color: $primary-content;
+        display: block;
+    }
+
+    & + span {
+        display: inline-block;
+        margin-left: 34px;
+        margin-bottom: 16px;
+        font-size: $font-15px;
+        line-height: $font-24px;
+        color: $secondary-content;
+
+        & + .mx_RadioButton {
+            border-top: 1px solid $menu-border-color;
+        }
+    }
+}
+
+.mx_JoinRuleSettings_linkButton {
+    padding: 0;
+    font-size: inherit;
+}
diff --git a/res/css/views/settings/_LayoutSwitcher.scss b/res/css/views/settings/_LayoutSwitcher.scss
new file mode 100644
index 0000000000..00fb8aba56
--- /dev/null
+++ b/res/css/views/settings/_LayoutSwitcher.scss
@@ -0,0 +1,91 @@
+/*
+Copyright 2020 - 2021 The Matrix.org Foundation C.I.C.
+Copyright 2021 Šimon Brandner 
+
+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_LayoutSwitcher {
+    .mx_LayoutSwitcher_RadioButtons {
+        display: flex;
+        flex-direction: row;
+        gap: 24px;
+
+        color: $primary-content;
+
+        > .mx_LayoutSwitcher_RadioButton {
+            flex-grow: 0;
+            flex-shrink: 1;
+            display: flex;
+            flex-direction: column;
+
+            width: 300px;
+
+            border: 1px solid $appearance-tab-border-color;
+            border-radius: 10px;
+
+            .mx_EventTile_msgOption,
+            .mx_MessageActionBar {
+                display: none;
+            }
+
+            .mx_LayoutSwitcher_RadioButton_preview {
+                flex-grow: 1;
+                display: flex;
+                align-items: center;
+                padding: 10px;
+                pointer-events: none;
+            }
+
+            .mx_RadioButton {
+                flex-grow: 0;
+                padding: 10px;
+            }
+
+            .mx_EventTile_content {
+                margin-right: 0;
+            }
+
+            &.mx_LayoutSwitcher_RadioButton_selected {
+                border-color: $accent-color;
+            }
+        }
+
+        .mx_RadioButton {
+            border-top: 1px solid $appearance-tab-border-color;
+
+            > input + div {
+                border-color: rgba($muted-fg-color, 0.2);
+            }
+        }
+
+        .mx_RadioButton_checked {
+            background-color: rgba($accent-color, 0.08);
+        }
+
+        .mx_EventTile {
+            margin: 0;
+            &[data-layout=bubble] {
+                margin-right: 40px;
+            }
+            &[data-layout=irc] {
+                > a {
+                    display: none;
+                }
+            }
+            .mx_EventTile_line {
+                max-width: 90%;
+            }
+        }
+    }
+}
diff --git a/res/css/views/settings/_Notifications.scss b/res/css/views/settings/_Notifications.scss
index 77a7bc5b68..a0e46c0071 100644
--- a/res/css/views/settings/_Notifications.scss
+++ b/res/css/views/settings/_Notifications.scss
@@ -1,5 +1,5 @@
 /*
-Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2015 - 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.
@@ -14,82 +14,79 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-.mx_UserNotifSettings_tableRow {
-    display: table-row;
-}
+.mx_UserNotifSettings {
+    color: $primary-content; // override from default settings page styles
 
-.mx_UserNotifSettings_inputCell {
-    display: table-cell;
-    padding-bottom: 8px;
-    padding-right: 8px;
-    width: 16px;
-}
+    .mx_UserNotifSettings_pushRulesTable {
+        width: calc(100% + 12px); // +12px to line up center of 'Noisy' column with toggle switches
+        table-layout: fixed;
+        border-collapse: collapse;
+        border-spacing: 0;
+        margin-top: 40px;
 
-.mx_UserNotifSettings_labelCell {
-    padding-bottom: 8px;
-    width: 400px;
-    display: table-cell;
-}
+        tr > th {
+            font-weight: $font-semi-bold;
+        }
 
-.mx_UserNotifSettings_pushRulesTableWrapper {
-    padding-bottom: 8px;
-}
+        tr > th:first-child {
+            text-align: left;
+            font-size: $font-18px;
+        }
 
-.mx_UserNotifSettings_pushRulesTable {
-    width: 100%;
-    table-layout: fixed;
-}
+        tr > th:nth-child(n + 2) {
+            color: $secondary-content;
+            font-size: $font-12px;
+            vertical-align: middle;
+            width: 66px;
+        }
 
-.mx_UserNotifSettings_pushRulesTable thead {
-    font-weight: bold;
-}
+        tr > td:nth-child(n + 2) {
+            text-align: center;
+        }
 
-.mx_UserNotifSettings_pushRulesTable tbody th {
-    font-weight: 400;
-}
+        tr > td {
+            padding-top: 8px;
+        }
 
-.mx_UserNotifSettings_pushRulesTable tbody th:first-child {
-    text-align: left;
-}
+        // Override StyledRadioButton default styles
+        .mx_RadioButton {
+            justify-content: center;
 
-.mx_UserNotifSettings_keywords {
-    cursor: pointer;
-    color: $accent-color;
-}
+            .mx_RadioButton_content {
+                display: none;
+            }
 
-.mx_UserNotifSettings_devicesTable td {
-    padding-left: 20px;
-    padding-right: 20px;
-}
+            .mx_RadioButton_spacer {
+                display: none;
+            }
+        }
+    }
 
-.mx_UserNotifSettings_notifTable {
-    display: table;
-    position: relative;
-}
+    .mx_UserNotifSettings_floatingSection {
+        margin-top: 40px;
 
-.mx_UserNotifSettings_notifTable .mx_Spinner {
-    position: absolute;
-}
+        & > div:first-child { // section header
+            font-size: $font-18px;
+            font-weight: $font-semi-bold;
+        }
 
-.mx_NotificationSound_soundUpload {
-    display: none;
-}
+        > table {
+            border-collapse: collapse;
+            border-spacing: 0;
+            margin-top: 8px;
 
-.mx_NotificationSound_browse {
-    color: $accent-color;
-    border: 1px solid $accent-color;
-    background-color: transparent;
-}
+            tr > td:first-child {
+                // Just for a bit of spacing
+                padding-right: 8px;
+            }
+        }
+    }
 
-.mx_NotificationSound_save {
-    margin-left: 5px;
-    color: white;
-    background-color: $accent-color;
-}
+    .mx_UserNotifSettings_clearNotifsButton {
+        margin-top: 8px;
+    }
 
-.mx_NotificationSound_resetSound {
-    margin-top: 5px;
-    color: white;
-    border: $warning-color;
-    background-color: $warning-color;
+    .mx_TagComposer {
+        margin-top: 35px; // lots of distance from the last line of the table
+    }
 }
diff --git a/res/css/views/settings/_ProfileSettings.scss b/res/css/views/settings/_ProfileSettings.scss
index 4cbcb8e708..63a5fa7edf 100644
--- a/res/css/views/settings/_ProfileSettings.scss
+++ b/res/css/views/settings/_ProfileSettings.scss
@@ -16,6 +16,7 @@ limitations under the License.
 
 .mx_ProfileSettings_controls_topic {
     & > textarea {
+        font-family: inherit;
         resize: vertical;
     }
 }
diff --git a/res/css/views/settings/tabs/_SettingsTab.scss b/res/css/views/settings/tabs/_SettingsTab.scss
index 892f5fe744..5aa9db7e86 100644
--- a/res/css/views/settings/tabs/_SettingsTab.scss
+++ b/res/css/views/settings/tabs/_SettingsTab.scss
@@ -25,7 +25,7 @@ limitations under the License.
 .mx_SettingsTab_heading {
     font-size: $font-20px;
     font-weight: 600;
-    color: $primary-fg-color;
+    color: $primary-content;
     margin-bottom: 10px;
 }
 
@@ -36,9 +36,8 @@ limitations under the License.
 .mx_SettingsTab_subheading {
     font-size: $font-16px;
     display: block;
-    font-family: $font-family;
     font-weight: 600;
-    color: $primary-fg-color;
+    color: $primary-content;
     margin-bottom: 10px;
     margin-top: 12px;
 }
@@ -47,19 +46,25 @@ limitations under the License.
     color: $settings-subsection-fg-color;
     font-size: $font-14px;
     display: block;
-    margin: 10px 100px 10px 0; // Align with the rest of the view
+    margin: 10px 80px 10px 0; // Align with the rest of the view
 }
 
 .mx_SettingsTab_section {
+    $right-gutter: 80px;
+
     margin-bottom: 24px;
 
     .mx_SettingsFlag {
-        margin-right: 100px;
+        margin-right: $right-gutter;
         margin-bottom: 10px;
     }
 
+    > p {
+        margin-right: $right-gutter;
+    }
+
     &.mx_SettingsTab_subsectionText .mx_SettingsFlag {
-        margin-right: 0px !important;
+        margin-right: 0 !important;
     }
 }
 
@@ -67,12 +72,19 @@ limitations under the License.
     vertical-align: middle;
     display: inline-block;
     font-size: $font-14px;
-    color: $primary-fg-color;
+    color: $primary-content;
     max-width: calc(100% - $font-48px); // Force word wrap instead of colliding with the switch
     box-sizing: border-box;
     padding-right: 10px;
 }
 
+.mx_SettingsTab_section .mx_SettingsFlag .mx_SettingsFlag_microcopy {
+    margin-top: 4px;
+    font-size: $font-12px;
+    line-height: $font-15px;
+    color: $secondary-content;
+}
+
 .mx_SettingsTab_section .mx_SettingsFlag .mx_ToggleSwitch {
     float: right;
 }
diff --git a/res/css/views/settings/tabs/room/_SecurityRoomSettingsTab.scss b/res/css/views/settings/tabs/room/_SecurityRoomSettingsTab.scss
index 23dcc532b2..a3b3b17899 100644
--- a/res/css/views/settings/tabs/room/_SecurityRoomSettingsTab.scss
+++ b/res/css/views/settings/tabs/room/_SecurityRoomSettingsTab.scss
@@ -14,6 +14,13 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+.mx_SecurityRoomSettingsTab {
+    .mx_SettingsTab_showAdvanced {
+        padding: 0;
+        margin-bottom: 16px;
+    }
+}
+
 .mx_SecurityRoomSettingsTab_warning {
     display: block;
 
@@ -26,5 +33,7 @@ limitations under the License.
 }
 
 .mx_SecurityRoomSettingsTab_encryptionSection {
-    margin-bottom: 25px;
+    padding-bottom: 24px;
+    border-bottom: 1px solid $menu-border-color;
+    margin-bottom: 32px;
 }
diff --git a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss
index 94983a60bf..57c6e9b865 100644
--- a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss
+++ b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss
@@ -1,5 +1,5 @@
 /*
-Copyright 2020 The Matrix.org Foundation C.I.C.
+Copyright 2020 - 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.
@@ -15,8 +15,7 @@ limitations under the License.
 */
 
 .mx_AppearanceUserSettingsTab_fontSlider,
-.mx_AppearanceUserSettingsTab_fontSlider_preview,
-.mx_AppearanceUserSettingsTab_Layout {
+.mx_AppearanceUserSettingsTab_fontSlider_preview {
     @mixin mx_Settings_fullWidthField;
 }
 
@@ -25,7 +24,7 @@ limitations under the License.
 }
 
 .mx_AppearanceUserSettingsTab_fontScaling {
-    color: $primary-fg-color;
+    color: $primary-content;
 }
 
 .mx_AppearanceUserSettingsTab_fontSlider {
@@ -45,6 +44,11 @@ limitations under the License.
     border-radius: 10px;
     padding: 0 16px 9px 16px;
     pointer-events: none;
+    display: flow-root;
+
+    .mx_EventTile[data-layout=bubble] {
+        margin-top: 30px;
+    }
 
     .mx_EventTile_msgOption {
         display: none;
@@ -77,7 +81,7 @@ limitations under the License.
 
 .mx_AppearanceUserSettingsTab_themeSection {
     $radio-bg-color: $input-darker-bg-color;
-    color: $primary-fg-color;
+    color: $primary-content;
 
     > .mx_ThemeSelectors {
         display: flex;
@@ -151,69 +155,8 @@ limitations under the License.
     margin-left: calc($font-16px + 10px);
 }
 
-.mx_AppearanceUserSettingsTab_Layout_RadioButtons {
-    display: flex;
-    flex-direction: row;
-
-    color: $primary-fg-color;
-
-    .mx_AppearanceUserSettingsTab_spacer {
-        width: 24px;
-    }
-
-    > .mx_AppearanceUserSettingsTab_Layout_RadioButton {
-        flex-grow: 0;
-        flex-shrink: 1;
-        display: flex;
-        flex-direction: column;
-
-        width: 300px;
-
-        border: 1px solid $appearance-tab-border-color;
-        border-radius: 10px;
-
-        .mx_EventTile_msgOption,
-        .mx_MessageActionBar {
-            display: none;
-        }
-
-        .mx_AppearanceUserSettingsTab_Layout_RadioButton_preview {
-            flex-grow: 1;
-            display: flex;
-            align-items: center;
-            padding: 10px;
-            pointer-events: none;
-        }
-
-        .mx_RadioButton {
-            flex-grow: 0;
-            padding: 10px;
-        }
-
-        .mx_EventTile_content {
-            margin-right: 0;
-        }
-
-        &.mx_AppearanceUserSettingsTab_Layout_RadioButton_selected {
-            border-color: $accent-color;
-        }
-    }
-
-    .mx_RadioButton {
-        border-top: 1px solid $appearance-tab-border-color;
-
-        > input + div {
-            border-color: rgba($muted-fg-color, 0.2);
-        }
-    }
-
-    .mx_RadioButton_checked {
-        background-color: rgba($accent-color, 0.08);
-    }
-}
-
 .mx_AppearanceUserSettingsTab_Advanced {
-    color: $primary-fg-color;
+    color: $primary-content;
 
     > * {
         margin-bottom: 16px;
diff --git a/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss b/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss
index 0f879d209e..3e61e80a9d 100644
--- a/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss
+++ b/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss
@@ -28,28 +28,33 @@ limitations under the License.
     user-select: all;
 }
 
-.mx_HelpUserSettingsTab_accessToken {
+.mx_HelpUserSettingsTab_copy {
     display: flex;
-    justify-content: space-between;
     border-radius: 5px;
     border: solid 1px $light-fg-color;
     margin-bottom: 10px;
     margin-top: 10px;
     padding: 10px;
-}
+    width: max-content;
+    max-width: 100%;
 
-.mx_HelpUserSettingsTab_accessToken_copy {
-    flex-shrink: 0;
-    cursor: pointer;
-    margin-left: 20px;
-    display: inherit;
-}
+    .mx_HelpUserSettingsTab_copyButton {
+        flex-shrink: 0;
+        width: 20px;
+        height: 20px;
+        cursor: pointer;
+        margin-left: 20px;
+        display: block;
 
-.mx_HelpUserSettingsTab_accessToken_copy > div {
-    mask-image: url($copy-button-url);
-    background-color: $message-action-bar-fg-color;
-    margin-left: 5px;
-    width: 20px;
-    height: 20px;
-    background-repeat: no-repeat;
+        &::before {
+            content: "";
+
+            mask-image: url($copy-button-url);
+            background-color: $message-action-bar-fg-color;
+            width: 20px;
+            height: 20px;
+            display: block;
+            background-repeat: no-repeat;
+        }
+    }
 }
diff --git a/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss b/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss
index be0af9123b..16f607c95f 100644
--- a/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss
+++ b/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss
@@ -21,5 +21,37 @@ limitations under the License.
 
     .mx_SettingsTab_section {
         margin-bottom: 30px;
+
+        > details {
+            > summary {
+                cursor: pointer;
+                color: $primary-content;
+            }
+
+            & + .mx_SettingsFlag {
+                margin-top: 20px;
+            }
+        }
+    }
+
+    .mx_PreferencesUserSettingsTab_CommunityMigrator {
+        margin-right: 200px;
+
+        > div {
+            font-weight: $font-semi-bold;
+            font-size: $font-15px;
+            line-height: $font-18px;
+            color: $primary-content;
+            margin: 16px 0;
+
+            .mx_BaseAvatar {
+                margin-right: 12px;
+                vertical-align: middle;
+            }
+
+            .mx_AccessibleButton {
+                float: right;
+            }
+        }
     }
 }
diff --git a/res/css/views/spaces/_SpaceBasicSettings.scss b/res/css/views/spaces/_SpaceBasicSettings.scss
index 204ccab2b7..bff574ded3 100644
--- a/res/css/views/spaces/_SpaceBasicSettings.scss
+++ b/res/css/views/spaces/_SpaceBasicSettings.scss
@@ -16,7 +16,7 @@ limitations under the License.
 
 .mx_SpaceBasicSettings {
     .mx_Field {
-        margin: 32px 0;
+        margin: 24px 0;
     }
 
     .mx_SpaceBasicSettings_avatarContainer {
@@ -27,7 +27,7 @@ limitations under the License.
             position: relative;
             height: 80px;
             width: 80px;
-            background-color: $tertiary-fg-color;
+            background-color: $tertiary-content;
             border-radius: 16px;
         }
 
@@ -73,7 +73,7 @@ limitations under the License.
         }
     }
 
-    .mx_FormButton {
+    .mx_AccessibleButton_hasKind {
         padding: 8px 22px;
         margin-left: auto;
         display: block;
diff --git a/res/css/views/spaces/_SpaceCreateMenu.scss b/res/css/views/spaces/_SpaceCreateMenu.scss
index 88b9d8f693..7084c2f20e 100644
--- a/res/css/views/spaces/_SpaceCreateMenu.scss
+++ b/res/css/views/spaces/_SpaceCreateMenu.scss
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-$spacePanelWidth: 71px;
+$spacePanelWidth: 68px;
 
 .mx_SpaceCreateMenu_wrapper {
     // background blur everything except SpacePanel
@@ -28,7 +28,7 @@ $spacePanelWidth: 71px;
         padding: 24px;
         width: 480px;
         box-sizing: border-box;
-        background-color: $primary-bg-color;
+        background-color: $background;
         position: relative;
 
         > div {
@@ -40,16 +40,14 @@ $spacePanelWidth: 71px;
 
             > p {
                 font-size: $font-15px;
-                color: $secondary-fg-color;
-                margin: 0;
+                color: $secondary-content;
             }
-        }
 
-        // XXX remove this when spaces leaves Beta
-        .mx_BetaCard_betaPill {
-            position: absolute;
-            top: 24px;
-            right: 24px;
+            .mx_SpaceFeedbackPrompt {
+                border-top: 1px solid $input-border-color;
+                padding-top: 12px;
+                margin-top: 16px;
+            }
         }
 
         .mx_SpaceCreateMenuType {
@@ -78,7 +76,7 @@ $spacePanelWidth: 71px;
                 width: 28px;
                 top: 0;
                 left: 0;
-                background-color: $tertiary-fg-color;
+                background-color: $tertiary-content;
                 transform: rotate(90deg);
                 mask-repeat: no-repeat;
                 mask-position: 2px 3px;
@@ -94,8 +92,35 @@ $spacePanelWidth: 71px;
             width: min-content;
         }
 
+        .mx_AccessibleButton_kind_link {
+            padding: 0;
+            font-size: inherit;
+        }
+
         .mx_AccessibleButton_disabled {
             cursor: not-allowed;
         }
     }
 }
+
+.mx_SpaceFeedbackPrompt {
+    font-size: $font-15px;
+    line-height: $font-24px;
+
+    > span {
+        color: $secondary-content;
+        position: relative;
+        font-size: inherit;
+        line-height: inherit;
+        margin-right: auto;
+    }
+
+    .mx_AccessibleButton_kind_link {
+        color: $accent-color;
+        position: relative;
+        padding: 0;
+        margin-left: 8px;
+        font-size: inherit;
+        line-height: inherit;
+    }
+}
diff --git a/res/css/views/toasts/_IncomingCallToast.scss b/res/css/views/toasts/_IncomingCallToast.scss
new file mode 100644
index 0000000000..cb05b1a977
--- /dev/null
+++ b/res/css/views/toasts/_IncomingCallToast.scss
@@ -0,0 +1,156 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+Copyright 2021 Šimon Brandner 
+
+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_IncomingCallToast {
+    display: flex;
+    flex-direction: row;
+    pointer-events: initial; // restore pointer events so the user can accept/decline
+
+    .mx_IncomingCallToast_content {
+        display: flex;
+        flex-direction: column;
+        margin-left: 8px;
+
+        .mx_CallEvent_caller {
+            font-weight: bold;
+            font-size: $font-15px;
+            line-height: $font-18px;
+
+            overflow: hidden;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+
+            margin-top: 2px;
+            margin-right: 6px;
+
+            max-width: 200px;
+        }
+
+        .mx_CallEvent_type {
+            font-size: $font-12px;
+            line-height: $font-15px;
+            color: $tertiary-content;
+
+            margin-top: 4px;
+            margin-bottom: 6px;
+
+            display: flex;
+            flex-direction: row;
+            align-items: center;
+
+            .mx_CallEvent_type_icon {
+                height: 16px;
+                width: 16px;
+                margin-right: 6px;
+
+                &::before {
+                    content: '';
+                    position: absolute;
+                    height: inherit;
+                    width: inherit;
+                    background-color: $tertiary-content;
+                    mask-repeat: no-repeat;
+                    mask-size: contain;
+                }
+            }
+        }
+
+        &.mx_IncomingCallToast_content_voice {
+            .mx_CallEvent_type .mx_CallEvent_type_icon::before,
+            .mx_IncomingCallToast_buttons .mx_IncomingCallToast_button_accept span::before {
+                mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
+            }
+        }
+
+        &.mx_IncomingCallToast_content_video {
+            .mx_CallEvent_type .mx_CallEvent_type_icon::before,
+            .mx_IncomingCallToast_buttons .mx_IncomingCallToast_button_accept span::before {
+                mask-image: url('$(res)/img/element-icons/call/video-call.svg');
+            }
+        }
+
+        .mx_IncomingCallToast_buttons {
+            margin-top: 8px;
+            display: flex;
+            flex-direction: row;
+            gap: 12px;
+
+            .mx_IncomingCallToast_button {
+                height: 24px;
+                padding: 0px 8px;
+                flex-shrink: 0;
+                flex-grow: 1;
+                margin-right: 0;
+                font-size: $font-15px;
+                line-height: $font-24px;
+
+                span {
+                    padding: 8px 0;
+                    display: flex;
+                    align-items: center;
+
+                    &::before {
+                        content: '';
+                        display: inline-block;
+                        background-color: $button-fg-color;
+                        mask-position: center;
+                        mask-repeat: no-repeat;
+                        margin-right: 8px;
+                    }
+                }
+
+                &.mx_IncomingCallToast_button_accept span::before {
+                    mask-size: 13px;
+                    width: 13px;
+                    height: 13px;
+                }
+
+                &.mx_IncomingCallToast_button_decline span::before {
+                    mask-image: url('$(res)/img/element-icons/call/hangup.svg');
+                    mask-size: 16px;
+                    width: 16px;
+                    height: 16px;
+                }
+            }
+        }
+    }
+
+    .mx_IncomingCallToast_iconButton {
+        display: flex;
+        height: 20px;
+        width: 20px;
+
+        &::before {
+            content: '';
+
+            height: inherit;
+            width: inherit;
+            background-color: $tertiary-content;
+            mask-repeat: no-repeat;
+            mask-size: contain;
+            mask-position: center;
+        }
+    }
+
+    .mx_IncomingCallToast_silence::before {
+        mask-image: url('$(res)/img/voip/silence.svg');
+    }
+
+    .mx_IncomingCallToast_unSilence::before {
+        mask-image: url('$(res)/img/voip/un-silence.svg');
+    }
+}
diff --git a/res/css/views/voip/CallView/_CallViewButtons.scss b/res/css/views/voip/CallView/_CallViewButtons.scss
new file mode 100644
index 0000000000..8e343f0ff3
--- /dev/null
+++ b/res/css/views/voip/CallView/_CallViewButtons.scss
@@ -0,0 +1,102 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2020 - 2021 The Matrix.org Foundation C.I.C.
+Copyright 2021 Šimon Brandner 
+
+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_CallViewButtons {
+    position: absolute;
+    display: flex;
+    justify-content: center;
+    bottom: 5px;
+    opacity: 1;
+    transition: opacity 0.5s;
+    z-index: 200; // To be above _all_ feeds
+
+    &.mx_CallViewButtons_hidden {
+        opacity: 0.001; // opacity 0 can cause a re-layout
+        pointer-events: none;
+    }
+
+    .mx_CallViewButtons_button {
+        cursor: pointer;
+        margin-left: 2px;
+        margin-right: 2px;
+
+
+        &::before {
+            content: '';
+            display: inline-block;
+
+            height: 48px;
+            width: 48px;
+
+            background-repeat: no-repeat;
+            background-size: contain;
+            background-position: center;
+        }
+
+
+        &.mx_CallViewButtons_dialpad::before {
+            background-image: url('$(res)/img/voip/dialpad.svg');
+        }
+
+        &.mx_CallViewButtons_button_micOn::before {
+            background-image: url('$(res)/img/voip/mic-on.svg');
+        }
+
+        &.mx_CallViewButtons_button_micOff::before {
+            background-image: url('$(res)/img/voip/mic-off.svg');
+        }
+
+        &.mx_CallViewButtons_button_vidOn::before {
+            background-image: url('$(res)/img/voip/vid-on.svg');
+        }
+
+        &.mx_CallViewButtons_button_vidOff::before {
+            background-image: url('$(res)/img/voip/vid-off.svg');
+        }
+
+        &.mx_CallViewButtons_button_screensharingOn::before {
+            background-image: url('$(res)/img/voip/screensharing-on.svg');
+        }
+
+        &.mx_CallViewButtons_button_screensharingOff::before {
+            background-image: url('$(res)/img/voip/screensharing-off.svg');
+        }
+
+        &.mx_CallViewButtons_button_sidebarOn::before {
+            background-image: url('$(res)/img/voip/sidebar-on.svg');
+        }
+
+        &.mx_CallViewButtons_button_sidebarOff::before {
+            background-image: url('$(res)/img/voip/sidebar-off.svg');
+        }
+
+        &.mx_CallViewButtons_button_hangup::before {
+            background-image: url('$(res)/img/voip/hangup.svg');
+        }
+
+        &.mx_CallViewButtons_button_more::before {
+            background-image: url('$(res)/img/voip/more.svg');
+        }
+
+        &.mx_CallViewButtons_button_invisible {
+            visibility: hidden;
+            pointer-events: none;
+            position: absolute;
+        }
+    }
+}
diff --git a/res/css/views/voip/_CallContainer.scss b/res/css/views/voip/_CallContainer.scss
index 8262075559..a0137b18e8 100644
--- a/res/css/views/voip/_CallContainer.scss
+++ b/res/css/views/voip/_CallContainer.scss
@@ -26,77 +26,7 @@ limitations under the License.
     // different level.
     pointer-events: none;
 
-    .mx_CallPreview {
-        pointer-events: initial; // restore pointer events so the user can leave/interact
-        cursor: pointer;
-
-        .mx_CallView_video {
-            width: 350px;
-        }
-
-        .mx_VideoFeed_local {
-            border-radius: 8px;
-            overflow: hidden;
-        }
-    }
-
     .mx_AppTile_persistedWrapper div {
         min-width: 350px;
     }
-
-    .mx_IncomingCallBox {
-        min-width: 250px;
-        background-color: $voipcall-plinth-color;
-        padding: 8px;
-        box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08);
-        border-radius: 8px;
-
-        pointer-events: initial; // restore pointer events so the user can accept/decline
-        cursor: pointer;
-
-        .mx_IncomingCallBox_CallerInfo {
-            display: flex;
-            direction: row;
-
-            img, .mx_BaseAvatar_initial {
-                margin: 8px;
-            }
-
-            > div {
-                display: flex;
-                flex-direction: column;
-
-                justify-content: center;
-            }
-
-            h1, p {
-                margin: 0px;
-                padding: 0px;
-                font-size: $font-14px;
-                line-height: $font-16px;
-            }
-
-            h1 {
-                font-weight: bold;
-            }
-        }
-
-        .mx_IncomingCallBox_buttons {
-            padding: 8px;
-            display: flex;
-            flex-direction: row;
-
-            > .mx_IncomingCallBox_spacer {
-                width: 8px;
-            }
-
-            > * {
-                flex-shrink: 0;
-                flex-grow: 1;
-                margin-right: 0;
-                font-size: $font-15px;
-                line-height: $font-24px;
-            }
-        }
-    }
 }
diff --git a/res/css/views/voip/_CallPreview.scss b/res/css/views/voip/_CallPreview.scss
new file mode 100644
index 0000000000..0fd97d4676
--- /dev/null
+++ b/res/css/views/voip/_CallPreview.scss
@@ -0,0 +1,32 @@
+/*
+Copyright 2021 Šimon Brandner 
+
+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_CallPreview {
+    position: fixed;
+    left: 0;
+    top: 0;
+
+    pointer-events: initial; // restore pointer events so the user can leave/interact
+
+    .mx_VideoFeed_remote.mx_VideoFeed_voice {
+        min-height: 150px;
+    }
+
+    .mx_VideoFeed_local {
+        border-radius: 8px;
+        overflow: hidden;
+    }
+}
diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss
index 0be75be28c..aa0aa4e2a6 100644
--- a/res/css/views/voip/_CallView.scss
+++ b/res/css/views/voip/_CallView.scss
@@ -39,20 +39,20 @@ limitations under the License.
 .mx_CallView_pip {
     width: 320px;
     padding-bottom: 8px;
-    margin-top: 10px;
-    background-color: $voipcall-plinth-color;
+    background-color: $system;
     box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20);
     border-radius: 8px;
 
+    .mx_CallView_video_hold,
     .mx_CallView_voice {
         height: 180px;
     }
 
-    .mx_CallView_callControls {
+    .mx_CallViewButtons {
         bottom: 0px;
     }
 
-    .mx_CallView_callControls_button {
+    .mx_CallViewButtons_button {
         &::before {
             width: 36px;
             height: 36px;
@@ -68,7 +68,30 @@ limitations under the License.
 .mx_CallView_content {
     position: relative;
     display: flex;
+    justify-content: center;
     border-radius: 8px;
+
+    > .mx_VideoFeed {
+        width: 100%;
+        height: 100%;
+        border-width: 0 !important; // Override mx_VideoFeed_speaking
+
+        &.mx_VideoFeed_voice {
+            display: flex;
+            justify-content: center;
+            align-items: center;
+        }
+
+        .mx_VideoFeed_video {
+            height: 100%;
+            background-color: #000;
+        }
+
+        .mx_VideoFeed_mic {
+            left: 10px;
+            bottom: 10px;
+        }
+    }
 }
 
 .mx_CallView_voice {
@@ -177,202 +200,22 @@ limitations under the License.
     }
 }
 
-.mx_CallView_header {
-    height: 44px;
-    display: flex;
-    flex-direction: row;
-    align-items: center;
-    justify-content: left;
-    flex-shrink: 0;
-}
 
-.mx_CallView_header_callType {
-    font-size: 1.2rem;
-    font-weight: bold;
-    vertical-align: middle;
-}
-
-.mx_CallView_header_secondaryCallInfo {
-    &::before {
-        content: '·';
-        margin-left: 6px;
-        margin-right: 6px;
-    }
-}
-
-.mx_CallView_header_controls {
-    margin-left: auto;
-}
-
-.mx_CallView_header_button {
-    display: inline-block;
-    vertical-align: middle;
-    cursor: pointer;
-
-    &::before {
-        content: '';
-        display: inline-block;
-        height: 20px;
-        width: 20px;
-        vertical-align: middle;
-        background-color: $secondary-fg-color;
-        mask-repeat: no-repeat;
-        mask-size: contain;
-        mask-position: center;
-    }
-}
-
-.mx_CallView_header_button_fullscreen {
-    &::before {
-        mask-image: url('$(res)/img/element-icons/call/fullscreen.svg');
-    }
-}
-
-.mx_CallView_header_button_expand {
-    &::before {
-        mask-image: url('$(res)/img/element-icons/call/expand.svg');
-    }
-}
-
-.mx_CallView_header_callInfo {
-    margin-left: 12px;
-    margin-right: 16px;
-}
-
-.mx_CallView_header_roomName {
-    font-weight: bold;
-    font-size: 12px;
-    line-height: initial;
-    height: 15px;
-}
-
-.mx_CallView_secondaryCall_roomName {
-    margin-left: 4px;
-}
-
-.mx_CallView_header_callTypeSmall {
-    font-size: 12px;
-    color: $secondary-fg-color;
-    line-height: initial;
-    height: 15px;
-    overflow: hidden;
-    white-space: nowrap;
-    text-overflow: ellipsis;
-    max-width: 240px;
-}
-
-.mx_CallView_header_phoneIcon {
-    display: inline-block;
-    margin-right: 6px;
-    height: 16px;
-    width: 16px;
-    vertical-align: middle;
-
-    &::before {
-        content: '';
-        display: inline-block;
-        vertical-align: top;
-
-        height: 16px;
-        width: 16px;
-        background-color: $warning-color;
-        mask-repeat: no-repeat;
-        mask-size: contain;
-        mask-position: center;
-        mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
-    }
-}
-
-.mx_CallView_callControls {
-    position: absolute;
-    display: flex;
-    justify-content: center;
-    bottom: 5px;
-    width: 100%;
+.mx_CallView_presenting {
     opacity: 1;
     transition: opacity 0.5s;
+
+    position: absolute;
+    margin-top: 18px;
+    padding: 4px 8px;
+    border-radius: 4px;
+
+    // Same on both themes
+    color: white;
+    background-color: #17191c;
 }
 
-.mx_CallView_callControls_hidden {
+.mx_CallView_presenting_hidden {
     opacity: 0.001; // opacity 0 can cause a re-layout
     pointer-events: none;
 }
-
-.mx_CallView_callControls_button {
-    cursor: pointer;
-    margin-left: 8px;
-    margin-right: 8px;
-
-
-    &::before {
-        content: '';
-        display: inline-block;
-
-        height: 48px;
-        width: 48px;
-
-        background-repeat: no-repeat;
-        background-size: contain;
-        background-position: center;
-    }
-}
-
-.mx_CallView_callControls_dialpad {
-    margin-right: auto;
-    &::before {
-        background-image: url('$(res)/img/voip/dialpad.svg');
-    }
-}
-
-.mx_CallView_callControls_button_dialpad_hidden {
-    margin-right: auto;
-    cursor: initial;
-}
-
-.mx_CallView_callControls_button_micOn {
-    &::before {
-        background-image: url('$(res)/img/voip/mic-on.svg');
-    }
-}
-
-.mx_CallView_callControls_button_micOff {
-    &::before {
-        background-image: url('$(res)/img/voip/mic-off.svg');
-    }
-}
-
-.mx_CallView_callControls_button_vidOn {
-    &::before {
-        background-image: url('$(res)/img/voip/vid-on.svg');
-    }
-}
-
-.mx_CallView_callControls_button_vidOff {
-    &::before {
-        background-image: url('$(res)/img/voip/vid-off.svg');
-    }
-}
-
-.mx_CallView_callControls_button_hangup {
-    &::before {
-        background-image: url('$(res)/img/voip/hangup.svg');
-    }
-}
-
-.mx_CallView_callControls_button_more {
-    margin-left: auto;
-    &::before {
-        background-image: url('$(res)/img/voip/more.svg');
-    }
-}
-
-.mx_CallView_callControls_button_more_hidden {
-    margin-left: auto;
-    cursor: initial;
-}
-
-.mx_CallView_callControls_button_invisible {
-    visibility: hidden;
-    pointer-events: none;
-    position: absolute;
-}
diff --git a/res/css/views/voip/_CallViewForRoom.scss b/res/css/views/voip/_CallViewForRoom.scss
index 769e00338e..d23fcc18bc 100644
--- a/res/css/views/voip/_CallViewForRoom.scss
+++ b/res/css/views/voip/_CallViewForRoom.scss
@@ -39,7 +39,7 @@ limitations under the License.
                 width: 100%;
                 max-width: 64px;
 
-                background-color: $primary-fg-color;
+                background-color: $primary-content;
             }
         }
     }
diff --git a/res/css/views/voip/_CallViewHeader.scss b/res/css/views/voip/_CallViewHeader.scss
new file mode 100644
index 0000000000..0575f4f535
--- /dev/null
+++ b/res/css/views/voip/_CallViewHeader.scss
@@ -0,0 +1,129 @@
+/*
+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.
+*/
+
+.mx_CallViewHeader {
+    height: 44px;
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    justify-content: left;
+    flex-shrink: 0;
+    cursor: pointer;
+}
+
+.mx_CallViewHeader_callType {
+    font-size: 1.2rem;
+    font-weight: bold;
+    vertical-align: middle;
+}
+
+.mx_CallViewHeader_secondaryCallInfo {
+    &::before {
+        content: '·';
+        margin-left: 6px;
+        margin-right: 6px;
+    }
+}
+
+.mx_CallViewHeader_controls {
+    margin-left: auto;
+}
+
+.mx_CallViewHeader_button {
+    display: inline-block;
+    vertical-align: middle;
+    cursor: pointer;
+
+    &::before {
+        content: '';
+        display: inline-block;
+        height: 20px;
+        width: 20px;
+        vertical-align: middle;
+        background-color: $secondary-content;
+        mask-repeat: no-repeat;
+        mask-size: contain;
+        mask-position: center;
+    }
+}
+
+.mx_CallViewHeader_button_fullscreen {
+    &::before {
+        mask-image: url('$(res)/img/element-icons/call/fullscreen.svg');
+    }
+}
+
+.mx_CallViewHeader_button_expand {
+    &::before {
+        mask-image: url('$(res)/img/element-icons/call/expand.svg');
+    }
+}
+
+.mx_CallViewHeader_callInfo {
+    margin-left: 12px;
+    margin-right: 16px;
+}
+
+.mx_CallViewHeader_roomName {
+    font-weight: bold;
+    font-size: 12px;
+    line-height: initial;
+    height: 15px;
+}
+
+.mx_CallView_secondaryCall_roomName {
+    margin-left: 4px;
+}
+
+.mx_CallViewHeader_callTypeSmall {
+    font-size: 12px;
+    color: $secondary-content;
+    line-height: initial;
+    height: 15px;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+    max-width: 240px;
+}
+
+.mx_CallViewHeader_callTypeIcon {
+    display: inline-block;
+    margin-right: 6px;
+    height: 16px;
+    width: 16px;
+    vertical-align: middle;
+
+    &::before {
+        content: '';
+        display: inline-block;
+        vertical-align: top;
+
+        height: 16px;
+        width: 16px;
+        background-color: $secondary-content;
+        mask-repeat: no-repeat;
+        mask-size: contain;
+        mask-position: center;
+    }
+
+    &.mx_CallViewHeader_callTypeIcon_voice::before {
+        mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
+    }
+
+    &.mx_CallViewHeader_callTypeIcon_video::before {
+        mask-image: url('$(res)/img/element-icons/call/video-call.svg');
+    }
+}
diff --git a/res/css/views/voip/_CallViewSidebar.scss b/res/css/views/voip/_CallViewSidebar.scss
new file mode 100644
index 0000000000..fd9c76defc
--- /dev/null
+++ b/res/css/views/voip/_CallViewSidebar.scss
@@ -0,0 +1,60 @@
+/*
+Copyright 2021 Šimon Brandner 
+
+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_CallViewSidebar {
+    position: absolute;
+    right: 16px;
+    bottom: 16px;
+    z-index: 100; // To be above the primary feed
+
+    overflow: auto;
+
+    height: calc(100% - 32px); // Subtract the top and bottom padding
+    width: 20%;
+
+    display: flex;
+    flex-direction: column-reverse;
+    justify-content: flex-start;
+    align-items: flex-end;
+    gap: 12px;
+
+    > .mx_VideoFeed {
+        width: 100%;
+        border-radius: 4px;
+
+        &.mx_VideoFeed_voice {
+            display: flex;
+            align-items: center;
+            justify-content: center;
+        }
+
+        .mx_VideoFeed_video {
+            border-radius: 4px;
+        }
+
+        .mx_VideoFeed_mic {
+            left: 6px;
+            bottom: 6px;
+        }
+    }
+
+    &.mx_CallViewSidebar_pipMode {
+        top: 16px;
+        bottom: unset;
+        justify-content: flex-end;
+        gap: 4px;
+    }
+}
diff --git a/res/css/views/voip/_DialPad.scss b/res/css/views/voip/_DialPad.scss
index 0c7bff0ce8..288f1f5d31 100644
--- a/res/css/views/voip/_DialPad.scss
+++ b/res/css/views/voip/_DialPad.scss
@@ -16,23 +16,42 @@ limitations under the License.
 
 .mx_DialPad {
     display: grid;
+    row-gap: 16px;
+    column-gap: 0px;
+    margin-top: 24px;
+    margin-left: auto;
+    margin-right: auto;
+
+    /* squeeze the dial pad buttons together horizontally */
     grid-template-columns: repeat(3, 1fr);
-    gap: 16px;
 }
 
 .mx_DialPad_button {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+
     width: 40px;
     height: 40px;
-    background-color: $theme-button-bg-color;
+    background-color: $quinary-content;
     border-radius: 40px;
     font-size: 18px;
     font-weight: 600;
     text-align: center;
     vertical-align: middle;
-    line-height: 40px;
+    margin-left: auto;
+    margin-right: auto;
 }
 
-.mx_DialPad_deleteButton, .mx_DialPad_dialButton {
+.mx_DialPad_button .mx_DialPad_buttonSubText {
+    font-size: 8px;
+}
+
+.mx_DialPad_dialButton {
+    /* Always show the dial button in the center grid column */
+    grid-column: 2;
+    background-color: $accent-color;
+
     &::before {
         content: '';
         display: inline-block;
@@ -42,21 +61,7 @@ limitations under the License.
         mask-repeat: no-repeat;
         mask-size: 20px;
         mask-position: center;
-        background-color: $primary-bg-color;
-    }
-}
-
-.mx_DialPad_deleteButton {
-    background-color: $notice-primary-color;
-    &::before {
-        mask-image: url('$(res)/img/element-icons/call/delete.svg');
-        mask-position: 9px; // delete icon is right-heavy so have to be slightly to the left to look centered
-    }
-}
-
-.mx_DialPad_dialButton {
-    background-color: $accent-color;
-    &::before {
+        background-color: #FFF; // on all themes
         mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
     }
 }
diff --git a/res/css/views/voip/_DialPadContextMenu.scss b/res/css/views/voip/_DialPadContextMenu.scss
index 520f51cf93..d2014241e9 100644
--- a/res/css/views/voip/_DialPadContextMenu.scss
+++ b/res/css/views/voip/_DialPadContextMenu.scss
@@ -14,10 +14,40 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+.mx_DialPadContextMenu_dialPad .mx_DialPad {
+    row-gap: 16px;
+    column-gap: 32px;
+}
+
+.mx_DialPadContextMenuWrapper {
+    padding: 15px;
+}
+
 .mx_DialPadContextMenu_header {
-    margin-top: 12px;
-    margin-left: 12px;
-    margin-right: 12px;
+    border: none;
+    margin-top: 32px;
+    margin-left: 20px;
+    margin-right: 20px;
+
+    /* a separator between the input line and the dial buttons */
+    border-bottom: 1px solid $quaternary-content;
+    transition: border-bottom 0.25s;
+}
+
+.mx_DialPadContextMenu_cancel {
+    float: right;
+    mask: url('$(res)/img/feather-customised/cancel.svg');
+    mask-repeat: no-repeat;
+    mask-position: center;
+    mask-size: cover;
+    width: 14px;
+    height: 14px;
+    background-color: $dialog-close-fg-color;
+    cursor: pointer;
+}
+
+.mx_DialPadContextMenu_header:focus-within {
+    border-bottom: 1px solid $accent-color;
 }
 
 .mx_DialPadContextMenu_title {
@@ -27,21 +57,22 @@ limitations under the License.
 }
 
 .mx_DialPadContextMenu_dialled {
-    height: 1em;
+    height: 1.5em;
     font-size: 18px;
     font-weight: 600;
+    border: none;
+    margin: 0px;
+}
+.mx_DialPadContextMenu_dialled input {
+    font-size: 18px;
+    font-weight: 600;
+    overflow: hidden;
+    max-width: 185px;
+    text-align: left;
+    padding: 8px 0px;
+    background-color: rgb(0, 0, 0, 0);
 }
 
 .mx_DialPadContextMenu_dialPad {
     margin: 16px;
 }
-
-.mx_DialPadContextMenu_horizSep {
-    position: relative;
-    &::before {
-        content: '';
-        position: absolute;
-        width: 100%;
-        border-bottom: 1px solid $input-darker-bg-color;
-    }
-}
diff --git a/res/css/views/voip/_DialPadModal.scss b/res/css/views/voip/_DialPadModal.scss
index f9d7673a38..f378507f90 100644
--- a/res/css/views/voip/_DialPadModal.scss
+++ b/res/css/views/voip/_DialPadModal.scss
@@ -19,14 +19,23 @@ limitations under the License.
 }
 
 .mx_DialPadModal {
-    width: 192px;
-    height: 368px;
+    width: 292px;
+    height: 370px;
+    padding: 16px 0px 0px 0px;
 }
 
 .mx_DialPadModal_header {
-    margin-top: 12px;
-    margin-left: 12px;
-    margin-right: 12px;
+    margin-top: 32px;
+    margin-left: 40px;
+    margin-right: 40px;
+
+    /* a separator between the input line and the dial buttons */
+    border-bottom: 1px solid $quaternary-content;
+    transition: border-bottom 0.25s;
+}
+
+.mx_DialPadModal_header:focus-within {
+    border-bottom: 1px solid $accent-color;
 }
 
 .mx_DialPadModal_title {
@@ -45,11 +54,18 @@ limitations under the License.
     height: 14px;
     background-color: $dialog-close-fg-color;
     cursor: pointer;
+    margin-right: 16px;
 }
 
 .mx_DialPadModal_field {
     border: none;
     margin: 0px;
+    height: 30px;
+}
+
+.mx_DialPadModal_field .mx_Field_postfix {
+    /* Remove border separator between postfix and field content */
+    border-left: none;
 }
 
 .mx_DialPadModal_field input {
@@ -62,13 +78,3 @@ limitations under the License.
     margin-right: 16px;
     margin-top: 16px;
 }
-
-.mx_DialPadModal_horizSep {
-    position: relative;
-    &::before {
-        content: '';
-        position: absolute;
-        width: 100%;
-        border-bottom: 1px solid $input-darker-bg-color;
-    }
-}
diff --git a/res/css/views/voip/_VideoFeed.scss b/res/css/views/voip/_VideoFeed.scss
index 7d85ac264e..1f17a54692 100644
--- a/res/css/views/voip/_VideoFeed.scss
+++ b/res/css/views/voip/_VideoFeed.scss
@@ -14,39 +14,65 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-.mx_VideoFeed_voice {
-    // We don't want to collide with the call controls that have 52px of height
-    padding-bottom: 52px;
-    background-color: $inverted-bg-color;
-}
-
-
-.mx_VideoFeed_remote {
-    width: 100%;
-    height: 100%;
+.mx_VideoFeed {
+    overflow: hidden;
+    position: relative;
+    box-sizing: border-box;
+    border: transparent 2px solid;
     display: flex;
-    justify-content: center;
-    align-items: center;
 
-    &.mx_VideoFeed_video {
-        background-color: #000;
+    &.mx_VideoFeed_voice {
+        background-color: $inverted-bg-color;
+        aspect-ratio: 16 / 9;
     }
-}
 
-.mx_VideoFeed_local {
-    max-width: 25%;
-    max-height: 25%;
-    position: absolute;
-    right: 10px;
-    top: 10px;
-    z-index: 100;
-    border-radius: 4px;
+    &.mx_VideoFeed_speaking {
+        border: $accent-color 2px solid;
 
-    &.mx_VideoFeed_video {
+        .mx_VideoFeed_video {
+            border-radius: 0;
+        }
+    }
+
+    .mx_VideoFeed_video {
+        width: 100%;
         background-color: transparent;
+
+        &.mx_VideoFeed_video_mirror {
+            transform: scale(-1, 1);
+        }
+    }
+
+    .mx_VideoFeed_mic {
+        position: absolute;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+
+        width: 24px;
+        height: 24px;
+
+        background-color: rgba(0, 0, 0, 0.5); // Same on both themes
+        border-radius: 100%;
+
+        &::before {
+            position: absolute;
+            content: "";
+            width: 16px;
+            height: 16px;
+            mask-repeat: no-repeat;
+            mask-size: contain;
+            mask-position: center;
+            background-color: white; // Same on both themes
+            border-radius: 7px;
+        }
+
+        &.mx_VideoFeed_mic_muted::before {
+            mask-image: url('$(res)/img/voip/mic-muted.svg');
+        }
+
+        &.mx_VideoFeed_mic_unmuted::before {
+            mask-image: url('$(res)/img/voip/mic-unmuted.svg');
+        }
     }
 }
-
-.mx_VideoFeed_mirror {
-    transform: scale(-1, 1);
-}
diff --git a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2
index a52e5a3800..128aac8139 100644
Binary files a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 and b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 differ
diff --git a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2
index 660a93193d..a95e89c094 100644
Binary files a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 and b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 differ
diff --git a/res/img/betas/.gitkeep b/res/img/betas/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/res/img/betas/spaces.png b/res/img/betas/spaces.png
deleted file mode 100644
index f4cfa90b4e..0000000000
Binary files a/res/img/betas/spaces.png and /dev/null differ
diff --git a/res/img/element-icons/call/dialpad.svg b/res/img/element-icons/call/dialpad.svg
new file mode 100644
index 0000000000..a97e80aa0b
--- /dev/null
+++ b/res/img/element-icons/call/dialpad.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/collapse-message.svg b/res/img/element-icons/collapse-message.svg
new file mode 100644
index 0000000000..91b0713f43
--- /dev/null
+++ b/res/img/element-icons/collapse-message.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/res/img/element-icons/expand-message.svg b/res/img/element-icons/expand-message.svg
new file mode 100644
index 0000000000..a1c5149718
--- /dev/null
+++ b/res/img/element-icons/expand-message.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/res/img/element-icons/export.svg b/res/img/element-icons/export.svg
new file mode 100644
index 0000000000..49899e9520
--- /dev/null
+++ b/res/img/element-icons/export.svg
@@ -0,0 +1,14 @@
+
+ 
+
diff --git a/res/img/element-icons/eye.svg b/res/img/element-icons/eye.svg
new file mode 100644
index 0000000000..0460a6201d
--- /dev/null
+++ b/res/img/element-icons/eye.svg
@@ -0,0 +1,3 @@
+
+    
+
diff --git a/res/img/element-icons/message/chevron-up.svg b/res/img/element-icons/message/chevron-up.svg
new file mode 100644
index 0000000000..4eb5ecc33e
--- /dev/null
+++ b/res/img/element-icons/message/chevron-up.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/res/img/element-icons/message/corner-up-right.svg b/res/img/element-icons/message/corner-up-right.svg
new file mode 100644
index 0000000000..0b8f961b7b
--- /dev/null
+++ b/res/img/element-icons/message/corner-up-right.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/res/img/element-icons/message/fwd.svg b/res/img/element-icons/message/fwd.svg
new file mode 100644
index 0000000000..8bcc70d092
--- /dev/null
+++ b/res/img/element-icons/message/fwd.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/message/link.svg b/res/img/element-icons/message/link.svg
new file mode 100644
index 0000000000..c89dd41c23
--- /dev/null
+++ b/res/img/element-icons/message/link.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/res/img/element-icons/message/repeat.svg b/res/img/element-icons/message/repeat.svg
new file mode 100644
index 0000000000..c7657b08ed
--- /dev/null
+++ b/res/img/element-icons/message/repeat.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/res/img/element-icons/message/share.svg b/res/img/element-icons/message/share.svg
new file mode 100644
index 0000000000..df38c14d63
--- /dev/null
+++ b/res/img/element-icons/message/share.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/res/img/element-icons/message/thread.svg b/res/img/element-icons/message/thread.svg
new file mode 100644
index 0000000000..b4a7cc0066
--- /dev/null
+++ b/res/img/element-icons/message/thread.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/res/img/element-icons/message/view-in-timeline.svg b/res/img/element-icons/message/view-in-timeline.svg
new file mode 100644
index 0000000000..9f05950ce0
--- /dev/null
+++ b/res/img/element-icons/message/view-in-timeline.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/res/img/element-icons/room/pin.svg b/res/img/element-icons/room/pin.svg
index 2448fc61c5..f090f60be8 100644
--- a/res/img/element-icons/room/pin.svg
+++ b/res/img/element-icons/room/pin.svg
@@ -1,7 +1,3 @@
 
-    
-    
-    
-    
-    
+    
 
diff --git a/res/img/element-icons/speaker.svg b/res/img/element-icons/speaker.svg
new file mode 100644
index 0000000000..fd811d2cda
--- /dev/null
+++ b/res/img/element-icons/speaker.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/res/img/element-icons/trashcan.svg b/res/img/element-icons/trashcan.svg
index f8fb8b5c46..4106f0bd60 100644
--- a/res/img/element-icons/trashcan.svg
+++ b/res/img/element-icons/trashcan.svg
@@ -1,3 +1,3 @@
-
-
+
+    
 
diff --git a/res/img/element-icons/warning-badge.svg b/res/img/element-icons/warning-badge.svg
index 1ae4e40ffe..1c8da9aa8e 100644
--- a/res/img/element-icons/warning-badge.svg
+++ b/res/img/element-icons/warning-badge.svg
@@ -1,5 +1,32 @@
-
-    
-    
-    
+
+
+  
+    
+      
+        image/svg+xml
+        
+        
+      
+    
+  
+  
+  
 
diff --git a/res/img/element-icons/warning.svg b/res/img/element-icons/warning.svg
new file mode 100644
index 0000000000..eef5193140
--- /dev/null
+++ b/res/img/element-icons/warning.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/feather-customised/globe.svg b/res/img/feather-customised/globe.svg
deleted file mode 100644
index 8af7dc41dc..0000000000
--- a/res/img/feather-customised/globe.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
-    
-        
-        
-        
-    
-
diff --git a/res/img/subtract.svg b/res/img/subtract.svg
new file mode 100644
index 0000000000..55e25831ef
--- /dev/null
+++ b/res/img/subtract.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/voip/declined-video.svg b/res/img/voip/declined-video.svg
new file mode 100644
index 0000000000..509ffa8fd1
--- /dev/null
+++ b/res/img/voip/declined-video.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/voip/declined-voice.svg b/res/img/voip/declined-voice.svg
new file mode 100644
index 0000000000..78e8d90cdf
--- /dev/null
+++ b/res/img/voip/declined-voice.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/res/img/voip/mic-muted.svg b/res/img/voip/mic-muted.svg
new file mode 100644
index 0000000000..0cb7ad1c9e
--- /dev/null
+++ b/res/img/voip/mic-muted.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/res/img/voip/mic-unmuted.svg b/res/img/voip/mic-unmuted.svg
new file mode 100644
index 0000000000..8334cafa0a
--- /dev/null
+++ b/res/img/voip/mic-unmuted.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/res/img/voip/missed-video.svg b/res/img/voip/missed-video.svg
new file mode 100644
index 0000000000..a2f3bc73ac
--- /dev/null
+++ b/res/img/voip/missed-video.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/voip/missed-voice.svg b/res/img/voip/missed-voice.svg
new file mode 100644
index 0000000000..5e3993584e
--- /dev/null
+++ b/res/img/voip/missed-voice.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/res/img/voip/screensharing-off.svg b/res/img/voip/screensharing-off.svg
new file mode 100644
index 0000000000..dc19e9892e
--- /dev/null
+++ b/res/img/voip/screensharing-off.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/voip/screensharing-on.svg b/res/img/voip/screensharing-on.svg
new file mode 100644
index 0000000000..a8e7fe308e
--- /dev/null
+++ b/res/img/voip/screensharing-on.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/voip/sidebar-off.svg b/res/img/voip/sidebar-off.svg
new file mode 100644
index 0000000000..7637a9ab55
--- /dev/null
+++ b/res/img/voip/sidebar-off.svg
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/voip/sidebar-on.svg b/res/img/voip/sidebar-on.svg
new file mode 100644
index 0000000000..a625334be4
--- /dev/null
+++ b/res/img/voip/sidebar-on.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/voip/silence.svg b/res/img/voip/silence.svg
new file mode 100644
index 0000000000..332932dfff
--- /dev/null
+++ b/res/img/voip/silence.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/voip/tab-dialpad.svg b/res/img/voip/tab-dialpad.svg
new file mode 100644
index 0000000000..b7add0addb
--- /dev/null
+++ b/res/img/voip/tab-dialpad.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/voip/tab-userdirectory.svg b/res/img/voip/tab-userdirectory.svg
new file mode 100644
index 0000000000..792ded7be4
--- /dev/null
+++ b/res/img/voip/tab-userdirectory.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/res/img/voip/un-silence.svg b/res/img/voip/un-silence.svg
new file mode 100644
index 0000000000..c00b366f84
--- /dev/null
+++ b/res/img/voip/un-silence.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss
index 2d0e3d2a8b..f012dff0a1 100644
--- a/res/themes/dark/css/_dark.scss
+++ b/res/themes/dark/css/_dark.scss
@@ -1,28 +1,37 @@
+// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=559%3A741
+$accent: #0DBD8B;
+$alert: #FF5B55;
+$links: #0086e6;
+$primary-content: #ffffff;
+$secondary-content: #A9B2BC;
+$tertiary-content: #8E99A4;
+$quaternary-content: #6F7882;
+$quinary-content: #394049;
+$system: #21262C;
+$background: #15191E;
+$panels: rgba($system, 0.9);
+$panel-base: #8D97A5; // This color is not intended for use in the app
+$panel-selected: rgba($panel-base, 0.3);
+$panel-hover: rgba($panel-base, 0.1);
+$panel-actions: rgba($panel-base, 0.2);
+$space-nav: rgba($panel-base, 0.1);
+
+// TODO: Move userId colors here
+
 // unified palette
 // try to use these colors when possible
-$bg-color: #15191E;
-$base-color: $bg-color;
-$base-text-color: #ffffff;
 $header-panel-bg-color: #20252B;
 $header-panel-border-color: #000000;
 $header-panel-text-primary-color: #B9BEC6;
 $header-panel-text-secondary-color: #c8c8cd;
-$text-primary-color: #ffffff;
 $text-secondary-color: #B9BEC6;
-$quaternary-fg-color: #6F7882;
 $search-bg-color: #181b21;
 $search-placeholder-color: #61708b;
 $room-highlight-color: #343a46;
 
 // typical text (dark-on-white in light skin)
-$primary-fg-color: $text-primary-color;
-$primary-bg-color: $bg-color;
 $muted-fg-color: $header-panel-text-primary-color;
 
-// additional text colors
-$secondary-fg-color: #A9B2BC;
-$tertiary-fg-color: #8E99A4;
-
 // used for dialog box text
 $light-fg-color: $header-panel-text-secondary-color;
 
@@ -41,13 +50,13 @@ $info-plinth-fg-color: #888;
 $preview-bar-bg-color: $header-panel-bg-color;
 
 $groupFilterPanel-bg-color: rgba(38, 39, 43, 0.82);
-$inverted-bg-color: $base-color;
+$inverted-bg-color: $background;
 
 // used by AddressSelector
 $selected-color: $room-highlight-color;
 
 // selected for hoverover & selected event tiles
-$event-selected-color: #21262c;
+$event-selected-color: $system;
 
 // used for the hairline dividers in RoomView
 $primary-hairline-color: transparent;
@@ -62,7 +71,7 @@ $input-focused-border-color: #238cf5;
 $input-valid-border-color: $accent-color;
 $input-invalid-border-color: $warning-color;
 
-$field-focused-label-bg-color: $bg-color;
+$field-focused-label-bg-color: $background;
 
 $resend-button-divider-color: #b9bec64a; // muted-text with a 4A opacity.
 
@@ -73,15 +82,15 @@ $scrollbar-track-color: transparent;
 // context menus
 $menu-border-color: $header-panel-border-color;
 $menu-bg-color: $header-panel-bg-color;
-$menu-box-shadow-color: $bg-color;
+$menu-box-shadow-color: $background;
 $menu-selected-color: $room-highlight-color;
 
 $avatar-initial-color: #ffffff;
-$avatar-bg-color: $bg-color;
+$avatar-bg-color: $background;
 
-$h3-color: $primary-fg-color;
+$h3-color: $primary-content;
 
-$dialog-title-fg-color: $base-text-color;
+$dialog-title-fg-color: $primary-content;
 $dialog-backdrop-color: #000;
 $dialog-shadow-color: rgba(0, 0, 0, 0.48);
 $dialog-close-fg-color: #9fa9ba;
@@ -91,44 +100,39 @@ $lightbox-background-bg-color: #000;
 $lightbox-background-bg-opacity: 0.85;
 
 $settings-grey-fg-color: #a2a2a2;
-$settings-profile-placeholder-bg-color: #21262c;
+$settings-profile-placeholder-bg-color: $system;
 $settings-profile-overlay-placeholder-fg-color: #454545;
 $settings-profile-button-bg-color: #e7e7e7;
 $settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color;
 $settings-subsection-fg-color: $text-secondary-color;
 
-$topleftmenu-color: $text-primary-color;
-$roomheader-color: $text-primary-color;
-$roomheader-bg-color: $bg-color;
+$topleftmenu-color: $primary-content;
+$roomheader-color: $primary-content;
 $roomheader-addroom-bg-color: rgba(92, 100, 112, 0.3);
-$roomheader-addroom-fg-color: $text-primary-color;
+$roomheader-addroom-fg-color: $primary-content;
 $groupFilterPanel-button-color: $header-panel-text-primary-color;
 $groupheader-button-color: $header-panel-text-primary-color;
 $rightpanel-button-color: $header-panel-text-primary-color;
-$icon-button-color: #8E99A4;
+$icon-button-color: $tertiary-content;
 $roomtopic-color: $text-secondary-color;
 $eventtile-meta-color: $roomtopic-color;
 
 $header-divider-color: $header-panel-text-primary-color;
 $composer-e2e-icon-color: $header-panel-text-primary-color;
 
-// this probably shouldn't have it's own colour
-$voipcall-plinth-color: #394049;
-
 // ********************
 
 $theme-button-bg-color: #e3e8f0;
 
 $roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
-$roomlist-filter-active-bg-color: $bg-color;
 $roomlist-bg-color: rgba(33, 38, 44, 0.90);
-$roomlist-header-color: $tertiary-fg-color;
-$roomsublist-divider-color: $primary-fg-color;
+$roomlist-header-color: $tertiary-content;
+$roomsublist-divider-color: $primary-content;
 $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #3e444c 0%, #3e444c00 100%);
 
 $groupFilterPanel-divider-color: $roomlist-header-color;
 
-$roomtile-preview-color: $secondary-fg-color;
+$roomtile-preview-color: $secondary-content;
 $roomtile-default-badge-bg-color: #61708b;
 $roomtile-selected-bg-color: rgba(141, 151, 165, 0.2);
 
@@ -152,20 +156,20 @@ $event-highlight-bg-color: #25271F;
 $event-timestamp-color: $text-secondary-color;
 
 // Tabbed views
-$tab-label-fg-color: $text-primary-color;
-$tab-label-active-fg-color: $text-primary-color;
+$tab-label-fg-color: $primary-content;
+$tab-label-active-fg-color: $primary-content;
 $tab-label-bg-color: transparent;
 $tab-label-active-bg-color: $accent-color;
-$tab-label-icon-bg-color: $text-primary-color;
-$tab-label-active-icon-bg-color: $text-primary-color;
+$tab-label-icon-bg-color: $primary-content;
+$tab-label-active-icon-bg-color: $primary-content;
 
 // Buttons
-$button-primary-fg-color: #ffffff;
+$button-primary-fg-color: $primary-content;
 $button-primary-bg-color: $accent-color;
 $button-secondary-bg-color: transparent;
-$button-danger-fg-color: #ffffff;
+$button-danger-fg-color: $primary-content;
 $button-danger-bg-color: $notice-primary-color;
-$button-danger-disabled-fg-color: #ffffff;
+$button-danger-disabled-fg-color: $primary-content;
 $button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color
 $button-link-fg-color: $accent-color;
 $button-link-bg-color: transparent;
@@ -174,12 +178,15 @@ $button-link-bg-color: transparent;
 $togglesw-off-color: $room-highlight-color;
 
 $progressbar-fg-color: $accent-color;
-$progressbar-bg-color: #21262c;
+$progressbar-bg-color: $system;
 
 $visual-bell-bg-color: #800;
 
 $room-warning-bg-color: $header-panel-bg-color;
 
+$authpage-body-bg-color: $background;
+$authpage-primary-color: $primary-content;
+
 $dark-panel-bg-color: $header-panel-bg-color;
 $panel-gradient: rgba(34, 38, 46, 0), rgba(34, 38, 46, 1);
 
@@ -197,37 +204,33 @@ $reaction-row-button-selected-border-color: $accent-color;
 $kbd-border-color: #000000;
 
 $tooltip-timeline-bg-color: $groupFilterPanel-bg-color;
-$tooltip-timeline-fg-color: #ffffff;
-
-$interactive-tooltip-bg-color: $base-color;
-$interactive-tooltip-fg-color: #ffffff;
+$tooltip-timeline-fg-color: $primary-content;
 
 $breadcrumb-placeholder-bg-color: #272c35;
 
-$user-tile-hover-bg-color: $header-panel-bg-color;
-
-$message-body-panel-fg-color: $secondary-fg-color;
-$message-body-panel-bg-color: #394049; // "Dark Tile"
-$message-body-panel-icon-fg-color: #21262C; // "Separator"
-$message-body-panel-icon-bg-color: $tertiary-fg-color;
-
-$voice-record-stop-border-color: $quaternary-fg-color;
-$voice-record-waveform-bg-color: $message-body-panel-bg-color;
-$voice-record-waveform-fg-color: $message-body-panel-fg-color;
-$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
-$voice-record-icon-color: $quaternary-fg-color;
-$voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
-$voice-playback-button-fg-color: $message-body-panel-icon-fg-color;
+$voice-record-stop-border-color: $quaternary-content;
+$voice-record-waveform-incomplete-fg-color: $quaternary-content;
+$voice-record-icon-color: $quaternary-content;
+$voice-playback-button-bg-color: $system;
+$voice-playback-button-fg-color: $secondary-content;
 
 // Appearance tab colors
 $appearance-tab-border-color: $room-highlight-color;
 
-// blur amounts for left left panel (only for element theme, used in _mods.scss)
-$roomlist-background-blur-amount: 60px;
-$groupFilterPanel-background-blur-amount: 30px;
+// blur amounts for left left panel (only for element theme)
+:root {
+    --lp-background-blur: 45px;
+}
 
 $composer-shadow-color: rgba(0, 0, 0, 0.28);
 
+// Bubble tiles
+$eventbubble-self-bg: #14322E;
+$eventbubble-others-bg: $event-selected-color;
+$eventbubble-bg-hover: #1C2026;
+$eventbubble-avatar-outline: $background;
+$eventbubble-reply-color: #C1C6CD;
+
 // ***** Mixins! *****
 
 @define-mixin mx_DialogButton {
@@ -273,24 +276,7 @@ $composer-shadow-color: rgba(0, 0, 0, 0.28);
 }
 
 // markdown overrides:
-.mx_EventTile_content .markdown-body pre:hover {
-    border-color: #808080 !important; // inverted due to rules below
-    scrollbar-color: rgba(0, 0, 0, 0.2) transparent; // copied from light theme due to inversion below
-    // the code above works only in Firefox, this is for other browsers
-    // see https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-color
-    &::-webkit-scrollbar-thumb {
-        background-color: rgba(0, 0, 0, 0.2); // copied from light theme due to inversion below
-    }
-}
 .mx_EventTile_content .markdown-body {
-    pre, code {
-        filter: invert(1);
-    }
-
-    pre code {
-        filter: none;
-    }
-
     table {
         tr {
             background-color: #000000;
@@ -300,18 +286,17 @@ $composer-shadow-color: rgba(0, 0, 0, 0.28);
             background-color: #080808;
         }
     }
-
-    blockquote {
-        color: #919191;
-    }
 }
 
-// diff highlight colors
-// intentionally swapped to avoid inversion
+// highlight.js overrides
+.hljs-tag {
+    color: inherit; // Without this they'd be weirdly blue which doesn't match the theme
+}
+
 .hljs-addition {
-    background: #fdd;
+    background: #1a4b59;
 }
 
 .hljs-deletion {
-    background: #dfd;
+    background: #53232a;
 }
diff --git a/res/themes/dark/css/dark.scss b/res/themes/dark/css/dark.scss
index f9695018e4..df83d6db88 100644
--- a/res/themes/dark/css/dark.scss
+++ b/res/themes/dark/css/dark.scss
@@ -2,10 +2,7 @@
 @import "../../light/css/_paths.scss";
 @import "../../light/css/_fonts.scss";
 @import "../../light/css/_light.scss";
-// important this goes before _mods,
-// as $groupFilterPanel-background-blur-amount and
-// $roomlist-background-blur-amount
-// are overridden in _dark.scss
 @import "_dark.scss";
 @import "../../light/css/_mods.scss";
 @import "../../../../res/css/_components.scss";
+@import url("highlight.js/styles/atom-one-dark.css");
diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss
index a852ad94e9..bed1e9c661 100644
--- a/res/themes/legacy-dark/css/_legacy-dark.scss
+++ b/res/themes/legacy-dark/css/_legacy-dark.scss
@@ -1,3 +1,6 @@
+// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=559%3A741
+$system: #21262C;
+
 // unified palette
 // try to use these colors when possible
 $bg-color: #181b21;
@@ -20,6 +23,14 @@ $tertiary-fg-color: $primary-fg-color;
 $primary-bg-color: $bg-color;
 $muted-fg-color: $header-panel-text-primary-color;
 
+// Legacy theme backports
+$primary-content: $primary-fg-color;
+$secondary-content: $secondary-fg-color;
+$tertiary-content: $tertiary-fg-color;
+$quaternary-content: #6F7882;
+$quinary-content: $quaternary-content;
+$background: $primary-bg-color;
+
 // used for dialog box text
 $light-fg-color: $header-panel-text-secondary-color;
 
@@ -108,13 +119,11 @@ $eventtile-meta-color: $roomtopic-color;
 $header-divider-color: $header-panel-text-primary-color;
 $composer-e2e-icon-color: $header-panel-text-primary-color;
 
-// this probably shouldn't have it's own colour
-$voipcall-plinth-color: #394049;
-
 // ********************
 
 $theme-button-bg-color: #e3e8f0;
 
+
 $roomlist-button-bg-color: #1A1D23; // Buttons include the filter box, explore button, and sublist buttons
 $roomlist-filter-active-bg-color: $roomlist-button-bg-color;
 $roomlist-bg-color: $header-panel-bg-color;
@@ -193,22 +202,10 @@ $kbd-border-color: #000000;
 $tooltip-timeline-bg-color: $groupFilterPanel-bg-color;
 $tooltip-timeline-fg-color: #ffffff;
 
-$interactive-tooltip-bg-color: $base-color;
-$interactive-tooltip-fg-color: #ffffff;
-
 $breadcrumb-placeholder-bg-color: #272c35;
 
-$user-tile-hover-bg-color: $header-panel-bg-color;
-
-$message-body-panel-fg-color: $secondary-fg-color;
-$message-body-panel-bg-color: #394049;
-$message-body-panel-icon-fg-color: $primary-bg-color;
-$message-body-panel-icon-bg-color: $secondary-fg-color;
-
 // See non-legacy dark for variable information
 $voice-record-stop-border-color: #6F7882;
-$voice-record-waveform-bg-color: $message-body-panel-bg-color;
-$voice-record-waveform-fg-color: $message-body-panel-fg-color;
 $voice-record-waveform-incomplete-fg-color: #6F7882;
 $voice-record-icon-color: #6F7882;
 $voice-playback-button-bg-color: $tertiary-fg-color;
@@ -219,6 +216,13 @@ $appearance-tab-border-color: $room-highlight-color;
 
 $composer-shadow-color: tranparent;
 
+// Bubble tiles
+$eventbubble-self-bg: #14322E;
+$eventbubble-others-bg: $event-selected-color;
+$eventbubble-bg-hover: #1C2026;
+$eventbubble-avatar-outline: $bg-color;
+$eventbubble-reply-color: #C1C6CD;
+
 // ***** Mixins! *****
 
 @define-mixin mx_DialogButton {
@@ -246,7 +250,7 @@ $composer-shadow-color: tranparent;
 @define-mixin mx_DialogButton_secondary {
     // flip colours for the secondary ones
     font-weight: 600;
-    border: 1px solid $accent-color ! important;
+    border: 1px solid $accent-color !important;
     color: $accent-color;
     background-color: $button-secondary-bg-color;
 }
@@ -264,18 +268,7 @@ $composer-shadow-color: tranparent;
 }
 
 // markdown overrides:
-.mx_EventTile_content .markdown-body pre:hover {
-    border-color: #808080 !important; // inverted due to rules below
-}
 .mx_EventTile_content .markdown-body {
-    pre, code {
-        filter: invert(1);
-    }
-
-    pre code {
-        filter: none;
-    }
-
     table {
         tr {
             background-color: #000000;
@@ -287,12 +280,7 @@ $composer-shadow-color: tranparent;
     }
 }
 
-// diff highlight colors
-// intentionally swapped to avoid inversion
-.hljs-addition {
-    background: #fdd;
-}
-
-.hljs-deletion {
-    background: #dfd;
+// highlight.js overrides:
+.hljs-tag {
+    color: inherit; // Without this they'd be weirdly blue which doesn't match the theme
 }
diff --git a/res/themes/legacy-dark/css/legacy-dark.scss b/res/themes/legacy-dark/css/legacy-dark.scss
index 2a4d432d26..840794f7c0 100644
--- a/res/themes/legacy-dark/css/legacy-dark.scss
+++ b/res/themes/legacy-dark/css/legacy-dark.scss
@@ -4,3 +4,4 @@
 @import "../../legacy-light/css/_legacy-light.scss";
 @import "_legacy-dark.scss";
 @import "../../../../res/css/_components.scss";
+@import url("highlight.js/styles/atom-one-dark.css");
diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss
index 84666bc662..2ce5b6062c 100644
--- a/res/themes/legacy-light/css/_legacy-light.scss
+++ b/res/themes/legacy-light/css/_legacy-light.scss
@@ -8,9 +8,12 @@
 /* Noto Color Emoji contains digits, in fixed-width, therefore causing
    digits in flowed text to stand out.
    TODO: Consider putting all emoji fonts to the end rather than the front. */
-$font-family: Nunito, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', Arial, Helvetica, Sans-Serif, 'Noto Color Emoji';
+$font-family: 'Nunito', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial', 'Helvetica', sans-serif, 'Noto Color Emoji';
 
-$monospace-font-family: Inconsolata, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', Courier, monospace, 'Noto Color Emoji';
+$monospace-font-family: 'Inconsolata', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Courier', monospace, 'Noto Color Emoji';
+
+// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=557%3A0
+$system: #F4F6FA;
 
 // unified palette
 // try to use these colors when possible
@@ -28,6 +31,14 @@ $tertiary-fg-color: $primary-fg-color;
 $primary-bg-color: #ffffff;
 $muted-fg-color: #61708b; // Commonly used in headings and relevant alt text
 
+// Legacy theme backports
+$primary-content: $primary-fg-color;
+$secondary-content: $secondary-fg-color;
+$tertiary-content: $tertiary-fg-color;
+$quaternary-content: #C1C6CD;
+$quinary-content: #e3e8f0;
+$background: $primary-bg-color;
+
 // used for dialog box text
 $light-fg-color: #747474;
 
@@ -175,8 +186,7 @@ $eventtile-meta-color: $roomtopic-color;
 $composer-e2e-icon-color: #91a1c0;
 $header-divider-color: #91a1c0;
 
-// this probably shouldn't have it's own colour
-$voipcall-plinth-color: #F4F6FA;
+$voipcall-plinth-color: $system;
 
 // ********************
 
@@ -316,34 +326,29 @@ $kbd-border-color: $reaction-row-button-border-color;
 $tooltip-timeline-bg-color: $groupFilterPanel-bg-color;
 $tooltip-timeline-fg-color: #ffffff;
 
-$interactive-tooltip-bg-color: #27303a;
-$interactive-tooltip-fg-color: #ffffff;
-
 $breadcrumb-placeholder-bg-color: #e8eef5;
 
-$user-tile-hover-bg-color: $header-panel-bg-color;
-
-$message-body-panel-fg-color: $secondary-fg-color;
-$message-body-panel-bg-color: #E3E8F0;
-$message-body-panel-icon-fg-color: $secondary-fg-color;
-$message-body-panel-icon-bg-color: $primary-bg-color;
-
 // See non-legacy _light for variable information
 $voice-record-stop-symbol-color: #ff4b55;
 $voice-record-live-circle-color: #ff4b55;
 $voice-record-stop-border-color: #E3E8F0;
-$voice-record-waveform-bg-color: $message-body-panel-bg-color;
-$voice-record-waveform-fg-color: $message-body-panel-fg-color;
 $voice-record-waveform-incomplete-fg-color: #C1C6CD;
 $voice-record-icon-color: $tertiary-fg-color;
-$voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
-$voice-playback-button-fg-color: $message-body-panel-icon-fg-color;
+$voice-playback-button-bg-color: $system;
+$voice-playback-button-fg-color: $secondary-content;
 
 // FontSlider colors
 $appearance-tab-border-color: $input-darker-bg-color;
 
 $composer-shadow-color: tranparent;
 
+// Bubble tiles
+$eventbubble-self-bg: #F0FBF8;
+$eventbubble-others-bg: $system;
+$eventbubble-bg-hover: #FAFBFD;
+$eventbubble-avatar-outline: #fff;
+$eventbubble-reply-color: #C1C6CD;
+
 // ***** Mixins! *****
 
 @define-mixin mx_DialogButton {
@@ -380,7 +385,7 @@ $composer-shadow-color: tranparent;
 @define-mixin mx_DialogButton_secondary {
     // flip colours for the secondary ones
     font-weight: 600;
-    border: 1px solid $accent-color ! important;
+    border: 1px solid $accent-color !important;
     color: $accent-color;
     background-color: $button-secondary-bg-color;
 }
diff --git a/res/themes/legacy-light/css/legacy-light.scss b/res/themes/legacy-light/css/legacy-light.scss
index e39a1765f3..347d240fc6 100644
--- a/res/themes/legacy-light/css/legacy-light.scss
+++ b/res/themes/legacy-light/css/legacy-light.scss
@@ -3,3 +3,4 @@
 @import "_fonts.scss";
 @import "_legacy-light.scss";
 @import "../../../../res/css/_components.scss";
+@import url("highlight.js/styles/atom-one-light.css");
diff --git a/res/themes/light-custom/css/_custom.scss b/res/themes/light-custom/css/_custom.scss
index 1b9254d100..af302bf252 100644
--- a/res/themes/light-custom/css/_custom.scss
+++ b/res/themes/light-custom/css/_custom.scss
@@ -16,6 +16,25 @@ limitations under the License.
 
 $font-family: var(--font-family, $font-family);
 $monospace-font-family: var(--font-family-monospace, $monospace-font-family);
+
+// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=559%3A741
+$accent: var(--accent);
+$alert: var(--alert);
+$links: var(--links);
+$primary-content: var(--primary-content);
+$secondary-content: var(--secondary-content);
+$tertiary-content: var(--tertiary-content);
+$quaternary-content: var(--quaternary-content);
+$quinary-content: var(--quinary-content);
+$system: var(--system);
+$background: var(--background);
+$panels: rgba($system, 0.9);
+$panel-base: var(--panel-base); // This color is not intended for use in the app
+$panel-selected: rgba($panel-base, 0.3);
+$panel-hover: rgba($panel-base, 0.1);
+$panel-actions: rgba($panel-base, 0.2);
+$space-nav: rgba($panel-base, 0.1);
+
 //
 // --accent-color
 $accent-color: var(--accent-color);
@@ -38,7 +57,7 @@ $lightbox-border-color: var(--timeline-background-color);
 $menu-bg-color: var(--timeline-background-color);
 $avatar-bg-color: var(--timeline-background-color);
 $message-action-bar-bg-color: var(--timeline-background-color);
-$primary-bg-color: var(--timeline-background-color);
+$background: var(--timeline-background-color);
 $togglesw-ball-color: var(--timeline-background-color);
 $droptarget-bg-color: var(--timeline-background-color-50pct); //still needs alpha at .5
 $authpage-modal-bg-color: var(--timeline-background-color-50pct); //still needs alpha at .59
@@ -48,7 +67,6 @@ $roomheader-bg-color: var(--timeline-background-color);
 $roomtile-selected-bg-color: var(--roomlist-highlights-color);
 //
 // --sidebar-color
-$interactive-tooltip-bg-color: var(--sidebar-color);
 $groupFilterPanel-bg-color: var(--sidebar-color);
 $tooltip-timeline-bg-color: var(--sidebar-color);
 $dialog-backdrop-color: var(--sidebar-color-50pct);
@@ -69,7 +87,7 @@ $roomlist-bg-color: var(--roomlist-background-color);
 //
 // --timeline-text-color
 $message-action-bar-fg-color: var(--timeline-text-color);
-$primary-fg-color: var(--timeline-text-color);
+$primary-content: var(--timeline-text-color);
 $settings-profile-overlay-placeholder-fg-color: var(--timeline-text-color);
 $roomtopic-color: var(--timeline-text-color-50pct);
 $tab-label-fg-color: var(--timeline-text-color);
@@ -82,6 +100,8 @@ $tab-label-fg-color: var(--timeline-text-color);
 // was #4e5054
 $authpage-lang-color: var(--timeline-text-color);
 $roomheader-color: var(--timeline-text-color);
+// was #232f32
+$authpage-primary-color: var(--timeline-text-color);
 // --roomlist-text-secondary-color
 $roomtile-preview-color: var(--roomlist-text-secondary-color);
 $roomlist-header-color: var(--roomlist-text-secondary-color);
@@ -139,4 +159,11 @@ $event-selected-color: var(--timeline-highlights-color);
 $event-highlight-bg-color: var(--timeline-highlights-color);
 //
 // redirect some variables away from their hardcoded values in the light theme
-$settings-grey-fg-color: $primary-fg-color;
+$settings-grey-fg-color: $primary-content;
+
+// --eventbubble colors
+$eventbubble-self-bg: var(--eventbubble-self-bg, $eventbubble-self-bg);
+$eventbubble-others-bg: var(--eventbubble-others-bg, $eventbubble-others-bg);
+$eventbubble-bg-hover: var(--eventbubble-bg-hover, $eventbubble-bg-hover);
+$eventbubble-avatar-outline: var(--eventbubble-avatar-outline, $eventbubble-avatar-outline);
+$eventbubble-reply-color: var(--eventbubble-reply-color, $eventbubble-reply-color);
diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss
index c889f43d0b..26cd1766c1 100644
--- a/res/themes/light/css/_light.scss
+++ b/res/themes/light/css/_light.scss
@@ -8,24 +8,38 @@
 /* Noto Color Emoji contains digits, in fixed-width, therefore causing
    digits in flowed text to stand out.
    TODO: Consider putting all emoji fonts to the end rather than the front. */
-$font-family: Inter, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', Arial, Helvetica, Sans-Serif, 'Noto Color Emoji';
+$font-family: 'Inter', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial', 'Helvetica', sans-serif, 'Noto Color Emoji';
 
-$monospace-font-family: Inconsolata, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', Courier, monospace, 'Noto Color Emoji';
+$monospace-font-family: 'Inconsolata', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Courier', monospace, 'Noto Color Emoji';
+
+// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=559%3A120
+$accent: #0DBD8B;
+$alert: #FF5B55;
+$links: #0086e6;
+$primary-content: #17191C;
+$secondary-content: #737D8C;
+$tertiary-content: #8D97A5;
+$quaternary-content: #c1c6cd;
+$quinary-content: #E3E8F0;
+$system: #F4F6FA;
+$background: #ffffff;
+$panels: rgba($system, 0.9);
+$panel-selected: rgba($tertiary-content, 0.3);
+$panel-hover: rgba($tertiary-content, 0.1);
+$panel-actions: rgba($tertiary-content, 0.2);
+$space-nav: rgba($tertiary-content, 0.15);
+
+// TODO: Move userId colors here
 
 // unified palette
 // try to use these colors when possible
-$accent-color: #0DBD8B;
+$accent-color: $accent;
 $accent-bg-color: rgba(3, 179, 129, 0.16);
 $notice-primary-color: #ff4b55;
 $notice-primary-bg-color: rgba(255, 75, 85, 0.16);
-$primary-fg-color: #2e2f32;
-$secondary-fg-color: #737D8C;
-$tertiary-fg-color: #8D99A5;
-$quaternary-fg-color: #C1C6CD;
 $header-panel-bg-color: #f3f8fd;
 
 // typical text (dark-on-white in light skin)
-$primary-bg-color: #ffffff;
 $muted-fg-color: #61708b; // Commonly used in headings and relevant alt text
 
 // used for dialog box text
@@ -35,12 +49,12 @@ $light-fg-color: #747474;
 $focus-bg-color: #dddddd;
 
 // button UI (white-on-green in light skin)
-$accent-fg-color: #ffffff;
+$accent-fg-color: $background;
 $accent-color-50pct: rgba($accent-color, 0.5);
 $accent-color-darker: #92caad;
 $accent-color-alt: #238CF5;
 
-$selection-fg-color: $primary-bg-color;
+$selection-fg-color: $background;
 
 $focus-brightness: 105%;
 
@@ -79,7 +93,7 @@ $primary-hairline-color: transparent;
 
 // used for the border of input text fields
 $input-border-color: #e7e7e7;
-$input-darker-bg-color: #e3e8f0;
+$input-darker-bg-color: $quinary-content;
 $input-darker-fg-color: #9fa9ba;
 $input-lighter-bg-color: #f2f5f8;
 $input-lighter-fg-color: $input-darker-fg-color;
@@ -87,7 +101,7 @@ $input-focused-border-color: #238cf5;
 $input-valid-border-color: $accent-color;
 $input-invalid-border-color: $warning-color;
 
-$field-focused-label-bg-color: #ffffff;
+$field-focused-label-bg-color: $background;
 
 $button-bg-color: $accent-color;
 $button-fg-color: white;
@@ -109,8 +123,8 @@ $menu-bg-color: #fff;
 $menu-box-shadow-color: rgba(118, 131, 156, 0.6);
 $menu-selected-color: #f5f8fa;
 
-$avatar-initial-color: #ffffff;
-$avatar-bg-color: #ffffff;
+$avatar-initial-color: $background;
+$avatar-bg-color: $background;
 
 $h3-color: #3d3b39;
 
@@ -138,7 +152,7 @@ $blockquote-bar-color: #ddd;
 $blockquote-fg-color: #777;
 
 $settings-grey-fg-color: #a2a2a2;
-$settings-profile-placeholder-bg-color: #f4f6fa;
+$settings-profile-placeholder-bg-color: $system;
 $settings-profile-overlay-placeholder-fg-color: #2e2f32;
 $settings-profile-button-bg-color: #e7e7e7;
 $settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color;
@@ -154,42 +168,39 @@ $rte-group-pill-color: #aaa;
 
 $topleftmenu-color: #212121;
 $roomheader-color: #45474a;
-$roomheader-bg-color: $primary-bg-color;
 $roomheader-addroom-bg-color: rgba(92, 100, 112, 0.2);
 $roomheader-addroom-fg-color: #5c6470;
 $groupFilterPanel-button-color: #91A1C0;
 $groupheader-button-color: #91A1C0;
 $rightpanel-button-color: #91A1C0;
-$icon-button-color: #C1C6CD;
+$icon-button-color: $quaternary-content;
 $roomtopic-color: #9e9e9e;
 $eventtile-meta-color: $roomtopic-color;
 
 $composer-e2e-icon-color: #91A1C0;
 $header-divider-color: #91A1C0;
 
-// this probably shouldn't have it's own colour
-$voipcall-plinth-color: #F4F6FA;
+$voipcall-plinth-color: $system;
 
 // ********************
 
-$theme-button-bg-color: #e3e8f0;
+$theme-button-bg-color: $quinary-content;
 
 $roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
-$roomlist-filter-active-bg-color: #ffffff;
 $roomlist-bg-color: rgba(245, 245, 245, 0.90);
-$roomlist-header-color: $tertiary-fg-color;
-$roomsublist-divider-color: $primary-fg-color;
+$roomlist-header-color: $tertiary-content;
+$roomsublist-divider-color: $primary-content;
 $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%);
 
 $groupFilterPanel-divider-color: $roomlist-header-color;
 
-$roomtile-preview-color: $secondary-fg-color;
+$roomtile-preview-color: $secondary-content;
 $roomtile-default-badge-bg-color: #61708b;
 $roomtile-selected-bg-color: #FFF;
 
 $presence-online: $accent-color;
 $presence-away: #d9b072;
-$presence-offline: #E3E8F0;
+$presence-offline: $quinary-content;
 
 // ********************
 
@@ -252,7 +263,7 @@ $lightbox-border-color: #ffffff;
 
 // Tabbed views
 $tab-label-fg-color: #45474a;
-$tab-label-active-fg-color: #ffffff;
+$tab-label-active-fg-color: $background;
 $tab-label-bg-color: transparent;
 $tab-label-active-bg-color: $accent-color;
 $tab-label-icon-bg-color: #454545;
@@ -262,9 +273,9 @@ $tab-label-active-icon-bg-color: $tab-label-active-fg-color;
 $button-primary-fg-color: #ffffff;
 $button-primary-bg-color: $accent-color;
 $button-secondary-bg-color: $accent-fg-color;
-$button-danger-fg-color: #ffffff;
+$button-danger-fg-color: $background;
 $button-danger-bg-color: $notice-primary-color;
-$button-danger-disabled-fg-color: #ffffff;
+$button-danger-disabled-fg-color: $background;
 $button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color
 $button-link-fg-color: $accent-color;
 $button-link-bg-color: transparent;
@@ -289,7 +300,7 @@ $memberstatus-placeholder-color: $muted-fg-color;
 
 $authpage-bg-color: #2e3649;
 $authpage-modal-bg-color: rgba(245, 245, 245, 0.90);
-$authpage-body-bg-color: #ffffff;
+$authpage-body-bg-color: $background;
 $authpage-focus-bg-color: #dddddd;
 $authpage-lang-color: #4e5054;
 $authpage-primary-color: #232f32;
@@ -298,8 +309,8 @@ $authpage-secondary-color: #61708b;
 $dark-panel-bg-color: $secondary-accent-color;
 $panel-gradient: rgba(242, 245, 248, 0), rgba(242, 245, 248, 1);
 
-$message-action-bar-bg-color: $primary-bg-color;
-$message-action-bar-fg-color: $primary-fg-color;
+$message-action-bar-bg-color: $background;
+$message-action-bar-fg-color: $primary-content;
 $message-action-bar-border-color: #e9edf1;
 $message-action-bar-hover-border-color: $focus-bg-color;
 
@@ -313,42 +324,37 @@ $kbd-border-color: $reaction-row-button-border-color;
 
 $inverted-bg-color: #27303a;
 $tooltip-timeline-bg-color: $inverted-bg-color;
-$tooltip-timeline-fg-color: #ffffff;
-
-$interactive-tooltip-bg-color: #27303a;
-$interactive-tooltip-fg-color: #ffffff;
+$tooltip-timeline-fg-color: $background;
 
 $breadcrumb-placeholder-bg-color: #e8eef5;
 
-$user-tile-hover-bg-color: $header-panel-bg-color;
-
-$message-body-panel-fg-color: $secondary-fg-color;
-$message-body-panel-bg-color: #E3E8F0; // "Separator"
-$message-body-panel-icon-fg-color: $secondary-fg-color;
-$message-body-panel-icon-bg-color: $primary-bg-color;
-
 // These two don't change between themes. They are the $warning-color, but we don't
 // want custom themes to affect them by accident.
 $voice-record-stop-symbol-color: #ff4b55;
 $voice-record-live-circle-color: #ff4b55;
 
-$voice-record-stop-border-color: #E3E8F0; // "Separator"
-$voice-record-waveform-bg-color: $message-body-panel-bg-color;
-$voice-record-waveform-fg-color: $message-body-panel-fg-color;
-$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
-$voice-record-icon-color: $tertiary-fg-color;
-$voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
-$voice-playback-button-fg-color: $message-body-panel-icon-fg-color;
+$voice-record-stop-border-color: $quinary-content;
+$voice-record-waveform-incomplete-fg-color: $quaternary-content;
+$voice-record-icon-color: $tertiary-content;
+$voice-playback-button-bg-color: $system;
+$voice-playback-button-fg-color: $secondary-content;
 
 // FontSlider colors
 $appearance-tab-border-color: $input-darker-bg-color;
 
-// blur amounts for left left panel (only for element theme, used in _mods.scss)
-$roomlist-background-blur-amount: 40px;
-$groupFilterPanel-background-blur-amount: 20px;
-
+// blur amounts for left left panel (only for element theme)
+:root {
+    --lp-background-blur: 40px;
+}
 $composer-shadow-color: rgba(0, 0, 0, 0.04);
 
+// Bubble tiles
+$eventbubble-self-bg: #F0FBF8;
+$eventbubble-others-bg: $system;
+$eventbubble-bg-hover: #FAFBFD;
+$eventbubble-avatar-outline: $background;
+$eventbubble-reply-color: $quaternary-content;
+
 // ***** Mixins! *****
 
 @define-mixin mx_DialogButton {
@@ -385,7 +391,7 @@ $composer-shadow-color: rgba(0, 0, 0, 0.04);
 @define-mixin mx_DialogButton_secondary {
     // flip colours for the secondary ones
     font-weight: 600;
-    border: 1px solid $accent-color ! important;
+    border: 1px solid $accent-color !important;
     color: $accent-color;
     background-color: $button-secondary-bg-color;
 }
diff --git a/res/themes/light/css/_mods.scss b/res/themes/light/css/_mods.scss
index fbca58dfb1..15f6d4b0fe 100644
--- a/res/themes/light/css/_mods.scss
+++ b/res/themes/light/css/_mods.scss
@@ -4,27 +4,6 @@
 // set the user avatar (if any) as a background so
 // it can be blurred by the tag panel and room list
 
-@supports (backdrop-filter: none) {
-    .mx_LeftPanel {
-        background-image: var(--avatar-url, unset);
-        background-repeat: no-repeat;
-        background-size: cover;
-        background-position: left top;
-    }
-
-    .mx_GroupFilterPanel {
-        backdrop-filter: blur($groupFilterPanel-background-blur-amount);
-    }
-
-    .mx_SpacePanel {
-        backdrop-filter: blur($groupFilterPanel-background-blur-amount);
-    }
-
-    .mx_LeftPanel .mx_LeftPanel_roomListContainer {
-        backdrop-filter: blur($roomlist-background-blur-amount);
-    }
-}
-
 .mx_RoomSublist_showNButton {
     background-color: transparent !important;
 }
diff --git a/res/themes/light/css/light.scss b/res/themes/light/css/light.scss
index f31ce5c139..4e912bc756 100644
--- a/res/themes/light/css/light.scss
+++ b/res/themes/light/css/light.scss
@@ -4,3 +4,4 @@
 @import "_light.scss";
 @import "_mods.scss";
 @import "../../../../res/css/_components.scss";
+@import url("highlight.js/styles/atom-one-light.css");
diff --git a/scripts/ci/Dockerfile b/scripts/ci/Dockerfile
index 3fdd0d7bf6..6d33987d8c 100644
--- a/scripts/ci/Dockerfile
+++ b/scripts/ci/Dockerfile
@@ -3,6 +3,6 @@
 # docker push vectorim/element-web-ci-e2etests-env:latest
 FROM node:14-buster
 RUN apt-get update
-RUN apt-get -y install build-essential python3-dev libffi-dev python-pip python-setuptools sqlite3 libssl-dev python-virtualenv libjpeg-dev libxslt1-dev uuid-runtime
+RUN apt-get -y install jq build-essential python3-dev libffi-dev python-pip python-setuptools sqlite3 libssl-dev python-virtualenv libjpeg-dev libxslt1-dev uuid-runtime
 # dependencies for chrome (installed by puppeteer)
-RUN apt-get -y install gconf-service libasound2 libatk1.0-0 libatk-bridge2.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget
+RUN apt-get -y install gconf-service libasound2 libatk1.0-0 libatk-bridge2.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm-dev libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget
diff --git a/scripts/ci/install-deps.sh b/scripts/ci/install-deps.sh
index bbda74ef9d..fcbf6b1198 100755
--- a/scripts/ci/install-deps.sh
+++ b/scripts/ci/install-deps.sh
@@ -6,8 +6,8 @@ scripts/fetchdep.sh matrix-org matrix-js-sdk
 
 pushd matrix-js-sdk
 yarn link
-yarn install $@
+yarn install --pure-lockfile $@
 popd
 
 yarn link matrix-js-sdk
-yarn install $@
+yarn install --pure-lockfile $@
diff --git a/scripts/ci/js-sdk-to-release.js b/scripts/ci/js-sdk-to-release.js
new file mode 100755
index 0000000000..e1fecfde03
--- /dev/null
+++ b/scripts/ci/js-sdk-to-release.js
@@ -0,0 +1,17 @@
+#!/usr/bin/env node
+
+const fsProm = require('fs/promises');
+
+const PKGJSON = 'node_modules/matrix-js-sdk/package.json';
+
+async function main() {
+    const pkgJson = JSON.parse(await fsProm.readFile(PKGJSON, 'utf8'));
+    for (const field of ['main', 'typings']) {
+        if (pkgJson["matrix_lib_"+field] !== undefined) {
+            pkgJson[field] = pkgJson["matrix_lib_"+field];
+        }
+    }
+    await fsProm.writeFile(PKGJSON, JSON.stringify(pkgJson, null, 2));
+}
+
+main();
diff --git a/scripts/ci/layered.sh b/scripts/ci/layered.sh
index 039f90c7df..2e163456fe 100755
--- a/scripts/ci/layered.sh
+++ b/scripts/ci/layered.sh
@@ -13,13 +13,13 @@
 scripts/fetchdep.sh matrix-org matrix-js-sdk
 pushd matrix-js-sdk
 yarn link
-yarn install
+yarn install --pure-lockfile
 popd
 
 # Now set up the react-sdk
 yarn link matrix-js-sdk
 yarn link
-yarn install
+yarn install --pure-lockfile
 yarn reskindex
 
 # Finally, set up element-web
@@ -27,6 +27,6 @@ scripts/fetchdep.sh vector-im element-web
 pushd element-web
 yarn link matrix-js-sdk
 yarn link matrix-react-sdk
-yarn install
+yarn install --pure-lockfile
 yarn build:res
 popd
diff --git a/scripts/ci/end-to-end-tests.sh b/scripts/ci/prepare-end-to-end-tests.sh
similarity index 65%
rename from scripts/ci/end-to-end-tests.sh
rename to scripts/ci/prepare-end-to-end-tests.sh
index edb8870d8e..147e1f6445 100755
--- a/scripts/ci/end-to-end-tests.sh
+++ b/scripts/ci/prepare-end-to-end-tests.sh
@@ -1,8 +1,4 @@
 #!/bin/bash
-#
-# script which is run by the CI build (after `yarn test`).
-#
-# clones element-web develop and runs the tests against our version of react-sdk.
 
 set -ev
 
@@ -19,7 +15,7 @@ cd element-web
 element_web_dir=`pwd`
 CI_PACKAGE=true yarn build
 cd ..
-# run end to end tests
+# prepare end to end tests
 pushd test/end-to-end-tests
 ln -s $element_web_dir element/element-web
 # PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ./install.sh
@@ -28,9 +24,4 @@ echo "--- Install synapse & other dependencies"
 ./install.sh
 # install static webserver to server symlinked local copy of element
 ./element/install-webserver.sh
-rm -r logs || true
-mkdir logs
-echo "+++ Running end-to-end tests"
-TESTS_STARTED=1
-./run.sh --no-sandbox --log-directory logs/
 popd
diff --git a/scripts/ci/run-end-to-end-tests.sh b/scripts/ci/run-end-to-end-tests.sh
new file mode 100755
index 0000000000..3c99391fc7
--- /dev/null
+++ b/scripts/ci/run-end-to-end-tests.sh
@@ -0,0 +1,19 @@
+#!/bin/bash
+
+set -ev
+
+handle_error() {
+    EXIT_CODE=$?
+    exit $EXIT_CODE
+}
+
+trap 'handle_error' ERR
+
+# run end to end tests
+pushd test/end-to-end-tests
+rm -r logs || true
+mkdir logs
+echo "--- Running end-to-end tests"
+TESTS_STARTED=1
+./run.sh --no-sandbox --log-directory logs/
+popd
diff --git a/scripts/fetchdep.sh b/scripts/fetchdep.sh
index fe1f49c361..ec021236d9 100755
--- a/scripts/fetchdep.sh
+++ b/scripts/fetchdep.sh
@@ -10,6 +10,7 @@ defbranch="$3"
 
 rm -r "$defrepo" || true
 
+# A function that clones a branch of a repo based on the org, repo and branch
 clone() {
     org=$1
     repo=$2
@@ -21,30 +22,45 @@ clone() {
     fi
 }
 
-# Try the PR author's branch in case it exists on the deps as well.
-# First we check if BUILDKITE_BRANCH is defined,
-# if it isn't we can assume this is a Netlify build
-if [ -z ${BUILDKITE_BRANCH+x} ]; then 
-	# Netlify doesn't give us info about the fork so we have to get it from GitHub API
-	apiEndpoint="https://api.github.com/repos/matrix-org/matrix-react-sdk/pulls/"
-	apiEndpoint+=$REVIEW_ID
-	head=$(curl $apiEndpoint | jq -r '.head.label')
-else 
-	head=$BUILDKITE_BRANCH
+# A function that gets info about a PR from the GitHub API based on its number
+getPRInfo() {
+    number=$1
+    if [ -n "$number" ]; then
+        echo "Getting info about a PR with number $number"
+
+        apiEndpoint="https://api.github.com/repos/matrix-org/matrix-react-sdk/pulls/"
+        apiEndpoint+=$number
+
+        head=$(curl $apiEndpoint | jq -r '.head.label')
+    fi
+}
+
+# Some CIs don't give us enough info, so we just get the PR number and ask the
+# GH API for more info - "fork:branch". Some give us this directly.
+if [ -n "$BUILDKITE_BRANCH" ]; then
+    # BuildKite
+    head=$BUILDKITE_BRANCH
+elif [ -n "$PR_NUMBER" ]; then
+    # GitHub
+    getPRInfo $PR_NUMBER
+elif [ -n "$REVIEW_ID" ]; then
+    # Netlify
+    getPRInfo $REVIEW_ID
 fi
 
-# If head is set, it will contain either:
-#   * "branch" when the author's branch and target branch are in the same repo
-#   * "fork:branch" when the author's branch is in their fork or if this is a Netlify build
-# We can split on `:` into an array to check.
+# $head will always be in the format "fork:branch", so we split it by ":" into
+# an array. The first element will then be the fork and the second the branch.
+# Based on that we clone
 BRANCH_ARRAY=(${head//:/ })
-if [[ "${#BRANCH_ARRAY[@]}" == "1" ]]; then
-    clone $deforg $defrepo $BUILDKITE_BRANCH
-elif [[ "${#BRANCH_ARRAY[@]}" == "2" ]]; then
-    clone ${BRANCH_ARRAY[0]} $defrepo ${BRANCH_ARRAY[1]}
-fi
+clone ${BRANCH_ARRAY[0]} $defrepo ${BRANCH_ARRAY[1]}
+
 # Try the target branch of the push or PR.
-clone $deforg $defrepo $BUILDKITE_PULL_REQUEST_BASE_BRANCH
+if [ -n $GITHUB_BASE_REF ]; then
+    clone $deforg $defrepo $GITHUB_BASE_REF
+elif [ -n $BUILDKITE_PULL_REQUEST_BASE_BRANCH ]; then
+    clone $deforg $defrepo $BUILDKITE_PULL_REQUEST_BASE_BRANCH
+fi
+
 # Try HEAD which is the branch name in Netlify (not BRANCH which is pull/xxxx/head for PR builds)
 clone $deforg $defrepo $HEAD
 # Use the default branch as the last resort.
diff --git a/scripts/generate-eslint-error-ignore-file b/scripts/generate-eslint-error-ignore-file
deleted file mode 100755
index 54aacfc9fa..0000000000
--- a/scripts/generate-eslint-error-ignore-file
+++ /dev/null
@@ -1,23 +0,0 @@
-#!/bin/sh
-#
-# generates .eslintignore.errorfiles to list the files which have errors in,
-# so that they can be ignored in future automated linting.
-
-out=.eslintignore.errorfiles
-
-cd `dirname $0`/..
-
-echo "generating $out"
-
-{
-    cat < 0) | .filePath' |
-        sed -e 's/.*matrix-react-sdk\///';
-} > "$out"
-# also append rules from eslintignore file
-cat .eslintignore >> $out
diff --git a/src/@types/common.ts b/src/@types/common.ts
index b887bd4090..36ef7a9ace 100644
--- a/src/@types/common.ts
+++ b/src/@types/common.ts
@@ -14,11 +14,12 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import { JSXElementConstructor } from "react";
+import React, { JSXElementConstructor } from "react";
 
 // Based on https://stackoverflow.com/a/53229857/3532235
-export type Without = {[P in Exclude] ? : never};
+export type Without = {[P in Exclude]?: never};
 export type XOR = (T | U) extends object ? (Without & U) | (Without & T) : T | U;
 export type Writeable = { -readonly [P in keyof T]: T[P] };
 
 export type ComponentClass = keyof JSX.IntrinsicElements | JSXElementConstructor;
+export type ReactAnyComponent = React.Component | React.ExoticComponent;
diff --git a/src/@types/diff-dom.ts b/src/@types/diff-dom.ts
new file mode 100644
index 0000000000..38ff6432cf
--- /dev/null
+++ b/src/@types/diff-dom.ts
@@ -0,0 +1,38 @@
+/*
+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.
+*/
+
+declare module "diff-dom" {
+    export interface IDiff {
+        action: string;
+        name: string;
+        text?: string;
+        route: number[];
+        value: string;
+        element: unknown;
+        oldValue: string;
+        newValue: string;
+    }
+
+    interface IOpts {
+    }
+
+    export class DiffDOM {
+        public constructor(opts?: IOpts);
+        public apply(tree: unknown, diffs: IDiff[]): unknown;
+        public undo(tree: unknown, diffs: IDiff[]): unknown;
+        public diff(a: HTMLElement | string, b: HTMLElement | string): IDiff[];
+    }
+}
diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts
index 22280b8a28..38f237b9c3 100644
--- a/src/@types/global.d.ts
+++ b/src/@types/global.d.ts
@@ -15,7 +15,10 @@ limitations under the License.
 */
 
 import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
-import * as ModernizrStatic from "modernizr";
+// Load types for the WG CSS Font Loading APIs https://github.com/Microsoft/TypeScript/issues/13569
+import "@types/css-font-loading-module";
+import "@types/modernizr";
+
 import ContentMessages from "../ContentMessages";
 import { IMatrixClientPeg } from "../MatrixClientPeg";
 import ToastStore from "../stores/ToastStore";
@@ -23,31 +26,37 @@ import DeviceListener from "../DeviceListener";
 import { RoomListStoreClass } from "../stores/room-list/RoomListStore";
 import { PlatformPeg } from "../PlatformPeg";
 import RoomListLayoutStore from "../stores/room-list/RoomListLayoutStore";
-import {IntegrationManagers} from "../integrations/IntegrationManagers";
-import {ModalManager} from "../Modal";
+import { IntegrationManagers } from "../integrations/IntegrationManagers";
+import { ModalManager } from "../Modal";
 import SettingsStore from "../settings/SettingsStore";
-import {ActiveRoomObserver} from "../ActiveRoomObserver";
-import {Notifier} from "../Notifier";
-import type {Renderer} from "react-dom";
+import { ActiveRoomObserver } from "../ActiveRoomObserver";
+import { Notifier } from "../Notifier";
+import type { Renderer } from "react-dom";
 import RightPanelStore from "../stores/RightPanelStore";
 import WidgetStore from "../stores/WidgetStore";
 import CallHandler from "../CallHandler";
-import {Analytics} from "../Analytics";
+import { Analytics } from "../Analytics";
 import CountlyAnalytics from "../CountlyAnalytics";
 import UserActivity from "../UserActivity";
-import {ModalWidgetStore} from "../stores/ModalWidgetStore";
+import { ModalWidgetStore } from "../stores/ModalWidgetStore";
 import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
 import VoipUserMapper from "../VoipUserMapper";
-import {SpaceStoreClass} from "../stores/SpaceStore";
+import { SpaceStoreClass } from "../stores/SpaceStore";
 import TypingStore from "../stores/TypingStore";
 import { EventIndexPeg } from "../indexing/EventIndexPeg";
-import {VoiceRecordingStore} from "../stores/VoiceRecordingStore";
+import { VoiceRecordingStore } from "../stores/VoiceRecordingStore";
 import PerformanceMonitor from "../performance";
 import UIStore from "../stores/UIStore";
+import { SetupEncryptionStore } from "../stores/SetupEncryptionStore";
+import { RoomScrollStateStore } from "../stores/RoomScrollStateStore";
+import { ConsoleLogger, IndexedDBLogStore } from "../rageshake/rageshake";
+import ActiveWidgetStore from "../stores/ActiveWidgetStore";
+import { Skinner } from "../Skinner";
+
+/* eslint-disable @typescript-eslint/naming-convention */
 
 declare global {
     interface Window {
-        Modernizr: ModernizrStatic;
         matrixChat: ReturnType;
         mxMatrixClientPeg: IMatrixClientPeg;
         Olm: {
@@ -84,6 +93,31 @@ declare global {
         mxPerformanceMonitor: PerformanceMonitor;
         mxPerformanceEntryNames: any;
         mxUIStore: UIStore;
+        mxSetupEncryptionStore?: SetupEncryptionStore;
+        mxRoomScrollStateStore?: RoomScrollStateStore;
+        mxActiveWidgetStore?: ActiveWidgetStore;
+        mxSkinner?: Skinner;
+        mxOnRecaptchaLoaded?: () => void;
+        electron?: Electron;
+    }
+
+    interface DesktopCapturerSource {
+        id: string;
+        name: string;
+        thumbnailURL: string;
+    }
+
+    interface GetSourcesOptions {
+        types: Array;
+        thumbnailSize?: {
+            height: number;
+            width: number;
+        };
+        fetchWindowIcons?: boolean;
+    }
+
+    interface Electron {
+        getDesktopCapturerSources(options: GetSourcesOptions): Promise>;
     }
 
     interface Document {
@@ -108,20 +142,7 @@ declare global {
     }
 
     interface StorageEstimate {
-        usageDetails?: {[key: string]: number};
-    }
-
-    export interface ISettledFulfilled {
-        status: "fulfilled";
-        value: T;
-    }
-    export interface ISettledRejected {
-        status: "rejected";
-        reason: any;
-    }
-
-    interface PromiseConstructor {
-        allSettled(promises: Promise[]): Promise | ISettledRejected>>;
+        usageDetails?: { [key: string]: number };
     }
 
     interface HTMLAudioElement {
@@ -138,11 +159,28 @@ declare global {
         setSinkId(outputId: string);
     }
 
+    interface HTMLStyleElement {
+        disabled?: boolean;
+    }
+
+    // Add Chrome-specific `instant` ScrollBehaviour
+    type _ScrollBehavior = ScrollBehavior | "instant";
+
+    interface _ScrollOptions {
+        behavior?: _ScrollBehavior;
+    }
+
+    interface _ScrollIntoViewOptions extends _ScrollOptions {
+        block?: ScrollLogicalPosition;
+        inline?: ScrollLogicalPosition;
+    }
+
     interface Element {
         // Safari & IE11 only have this prefixed: we used prefixed versions
         // previously so let's continue to support them for now
         webkitRequestFullScreen(options?: FullscreenOptions): Promise;
         msRequestFullscreen(options?: FullscreenOptions): Promise;
+        scrollIntoView(arg?: boolean | _ScrollIntoViewOptions): void;
     }
 
     interface Error {
@@ -179,4 +217,30 @@ declare global {
             parameterDescriptors?: AudioParamDescriptor[];
         }
     );
+
+    // eslint-disable-next-line no-var
+    var grecaptcha:
+        | undefined
+        | {
+              reset: (id: string) => void;
+              render: (
+                  divId: string,
+                  options: {
+                      sitekey: string;
+                      callback: (response: string) => void;
+                  },
+              ) => string;
+              isReady: () => boolean;
+          };
+
+    // eslint-disable-next-line no-var, camelcase
+    var mx_rage_logger: ConsoleLogger;
+    // eslint-disable-next-line no-var, camelcase
+    var mx_rage_initPromise: Promise;
+    // eslint-disable-next-line no-var, camelcase
+    var mx_rage_initStoragePromise: Promise;
+    // eslint-disable-next-line no-var, camelcase
+    var mx_rage_store: IndexedDBLogStore;
 }
+
+/* eslint-enable @typescript-eslint/naming-convention */
diff --git a/res/css/views/messages/_MVoiceMessageBody.scss b/src/@types/raw-loader.d.ts
similarity index 85%
rename from res/css/views/messages/_MVoiceMessageBody.scss
rename to src/@types/raw-loader.d.ts
index 3dfb98f778..efd825204e 100644
--- a/res/css/views/messages/_MVoiceMessageBody.scss
+++ b/src/@types/raw-loader.d.ts
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-.mx_MVoiceMessageBody {
-    display: inline-block; // makes the playback controls magically line up
+declare module '!!raw-loader!*' {
+    const contents: string;
+    export default contents;
 }
diff --git a/res/css/views/avatars/_PulsedAvatar.scss b/src/@types/svg.d.ts
similarity index 61%
rename from res/css/views/avatars/_PulsedAvatar.scss
rename to src/@types/svg.d.ts
index ce9e3382ab..96f671c52f 100644
--- a/res/css/views/avatars/_PulsedAvatar.scss
+++ b/src/@types/svg.d.ts
@@ -1,5 +1,5 @@
 /*
-Copyright 2020 The Matrix.org Foundation C.I.C.
+Copyright 2021 Šimon Brandner 
 
 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,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-.mx_PulsedAvatar {
-    @keyframes shadow-pulse {
-        0% {
-            box-shadow: 0 0 0 0px rgba($accent-color, 0.2);
-        }
-        100% {
-            box-shadow: 0 0 0 6px rgba($accent-color, 0);
-        }
-    }
-
-    img {
-        animation: shadow-pulse 1s infinite;
-    }
+declare module "*.svg" {
+    const path: string;
+    export default path;
 }
diff --git a/src/RoomNotifsTypes.ts b/src/@types/worker-loader.d.ts
similarity index 70%
rename from src/RoomNotifsTypes.ts
rename to src/@types/worker-loader.d.ts
index 0e7093e434..a8f5d8e9a4 100644
--- a/src/RoomNotifsTypes.ts
+++ b/src/@types/worker-loader.d.ts
@@ -1,5 +1,5 @@
 /*
-Copyright 2020 The Matrix.org Foundation C.I.C.
+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.
@@ -14,11 +14,10 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import {
-    ALL_MESSAGES,
-    ALL_MESSAGES_LOUD,
-    MENTIONS_ONLY,
-    MUTE,
-} from "./RoomNotifs";
+declare module "*.worker.ts" {
+    class WebpackWorker extends Worker {
+        constructor();
+    }
 
-export type Volume = ALL_MESSAGES_LOUD | ALL_MESSAGES | MENTIONS_ONLY | MUTE;
+    export default WebpackWorker;
+}
diff --git a/src/ActiveRoomObserver.ts b/src/ActiveRoomObserver.ts
index 1126dc9496..c7423fab8f 100644
--- a/src/ActiveRoomObserver.ts
+++ b/src/ActiveRoomObserver.ts
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+import { EventSubscription } from 'fbemitter';
 import RoomViewStore from './stores/RoomViewStore';
 
 type Listener = (isActive: boolean) => void;
@@ -30,7 +31,7 @@ type Listener = (isActive: boolean) => void;
 export class ActiveRoomObserver {
     private listeners: {[key: string]: Listener[]} = {};
     private _activeRoomId = RoomViewStore.getRoomId();
-    private readonly roomStoreToken: string;
+    private readonly roomStoreToken: EventSubscription;
 
     constructor() {
         // TODO: We could self-destruct when the last listener goes away, or at least stop listening.
diff --git a/src/AddThreepid.js b/src/AddThreepid.ts
similarity index 90%
rename from src/AddThreepid.js
rename to src/AddThreepid.ts
index f06f7c187d..54250c5eb3 100644
--- a/src/AddThreepid.js
+++ b/src/AddThreepid.ts
@@ -16,14 +16,15 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import {MatrixClientPeg} from './MatrixClientPeg';
-import * as sdk from './index';
+import { MatrixClientPeg } from './MatrixClientPeg';
 import Modal from './Modal';
 import { _t } from './languageHandler';
 import IdentityAuthClient from './IdentityAuthClient';
-import {SSOAuthEntry} from "./components/views/auth/InteractiveAuthEntryComponents";
+import { SSOAuthEntry } from "./components/views/auth/InteractiveAuthEntryComponents";
+import { IRequestMsisdnTokenResponse, IRequestTokenResponse } from "matrix-js-sdk";
+import InteractiveAuthDialog from "./components/views/dialogs/InteractiveAuthDialog";
 
-function getIdServerDomain() {
+function getIdServerDomain(): string {
     return MatrixClientPeg.get().idBaseUrl.split("://")[1];
 }
 
@@ -40,10 +41,13 @@ function getIdServerDomain() {
  * https://gist.github.com/jryans/839a09bf0c5a70e2f36ed990d50ed928
  */
 export default class AddThreepid {
+    private sessionId: string;
+    private submitUrl: string;
+    private clientSecret: string;
+    private bind: boolean;
+
     constructor() {
         this.clientSecret = MatrixClientPeg.get().generateClientSecret();
-        this.sessionId = null;
-        this.submitUrl = null;
     }
 
     /**
@@ -52,7 +56,7 @@ export default class AddThreepid {
      * @param {string} emailAddress The email address to add
      * @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked().
      */
-    addEmailAddress(emailAddress) {
+    public addEmailAddress(emailAddress: string): Promise {
         return MatrixClientPeg.get().requestAdd3pidEmailToken(emailAddress, this.clientSecret, 1).then((res) => {
             this.sessionId = res.sid;
             return res;
@@ -72,7 +76,7 @@ export default class AddThreepid {
      * @param {string} emailAddress The email address to add
      * @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked().
      */
-    async bindEmailAddress(emailAddress) {
+    public async bindEmailAddress(emailAddress: string): Promise {
         this.bind = true;
         if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) {
             // For separate bind, request a token directly from the IS.
@@ -105,7 +109,7 @@ export default class AddThreepid {
      * @param {string} phoneNumber The national or international formatted phone number to add
      * @return {Promise} Resolves when the text message has been sent. Then call haveMsisdnToken().
      */
-    addMsisdn(phoneCountry, phoneNumber) {
+    public addMsisdn(phoneCountry: string, phoneNumber: string): Promise {
         return MatrixClientPeg.get().requestAdd3pidMsisdnToken(
             phoneCountry, phoneNumber, this.clientSecret, 1,
         ).then((res) => {
@@ -129,7 +133,7 @@ export default class AddThreepid {
      * @param {string} phoneNumber The national or international formatted phone number to add
      * @return {Promise} Resolves when the text message has been sent. Then call haveMsisdnToken().
      */
-    async bindMsisdn(phoneCountry, phoneNumber) {
+    public async bindMsisdn(phoneCountry: string, phoneNumber: string): Promise {
         this.bind = true;
         if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) {
             // For separate bind, request a token directly from the IS.
@@ -161,7 +165,7 @@ export default class AddThreepid {
      * with a "message" property which contains a human-readable message detailing why
      * the request failed.
      */
-    async checkEmailLinkClicked() {
+    public async checkEmailLinkClicked(): Promise {
         try {
             if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) {
                 if (this.bind) {
@@ -175,7 +179,7 @@ export default class AddThreepid {
                     });
                 } else {
                     try {
-                        await this._makeAddThreepidOnlyRequest();
+                        await this.makeAddThreepidOnlyRequest();
 
                         // The spec has always required this to use UI auth but synapse briefly
                         // implemented it without, so this may just succeed and that's OK.
@@ -186,10 +190,6 @@ export default class AddThreepid {
                             throw e;
                         }
 
-                        // pop up an interactive auth dialog
-                        const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
-
-
                         const dialogAesthetics = {
                             [SSOAuthEntry.PHASE_PREAUTH]: {
                                 title: _t("Use Single Sign On to continue"),
@@ -209,7 +209,7 @@ export default class AddThreepid {
                             title: _t("Add Email Address"),
                             matrixClient: MatrixClientPeg.get(),
                             authData: e.data,
-                            makeRequest: this._makeAddThreepidOnlyRequest,
+                            makeRequest: this.makeAddThreepidOnlyRequest,
                             aestheticsForStagePhases: {
                                 [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
                                 [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
@@ -236,26 +236,26 @@ export default class AddThreepid {
     }
 
     /**
-     * @param {Object} auth UI auth object
+     * @param {{type: string, session?: string}} auth UI auth object
      * @return {Promise} Response from /3pid/add call (in current spec, an empty object)
      */
-    _makeAddThreepidOnlyRequest = (auth) => {
+    private makeAddThreepidOnlyRequest = (auth?: {type: string, session?: string}): Promise<{}> => {
         return MatrixClientPeg.get().addThreePidOnly({
             sid: this.sessionId,
             client_secret: this.clientSecret,
             auth,
         });
-    }
+    };
 
     /**
      * Takes a phone number verification code as entered by the user and validates
-     * it with the ID server, then if successful, adds the phone number.
+     * it with the identity server, then if successful, adds the phone number.
      * @param {string} msisdnToken phone number verification code as entered by the user
      * @return {Promise} Resolves if the phone number was added. Rejects with an object
      * with a "message" property which contains a human-readable message detailing why
      * the request failed.
      */
-    async haveMsisdnToken(msisdnToken) {
+    public async haveMsisdnToken(msisdnToken: string): Promise {
         const authClient = new IdentityAuthClient();
         const supportsSeparateAddAndBind =
             await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind();
@@ -292,7 +292,7 @@ export default class AddThreepid {
                 });
             } else {
                 try {
-                    await this._makeAddThreepidOnlyRequest();
+                    await this.makeAddThreepidOnlyRequest();
 
                     // The spec has always required this to use UI auth but synapse briefly
                     // implemented it without, so this may just succeed and that's OK.
@@ -303,9 +303,6 @@ export default class AddThreepid {
                         throw e;
                     }
 
-                    // pop up an interactive auth dialog
-                    const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
-
                     const dialogAesthetics = {
                         [SSOAuthEntry.PHASE_PREAUTH]: {
                             title: _t("Use Single Sign On to continue"),
@@ -325,7 +322,7 @@ export default class AddThreepid {
                         title: _t("Add Phone Number"),
                         matrixClient: MatrixClientPeg.get(),
                         authData: e.data,
-                        makeRequest: this._makeAddThreepidOnlyRequest,
+                        makeRequest: this.makeAddThreepidOnlyRequest,
                         aestheticsForStagePhases: {
                             [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
                             [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
diff --git a/src/Analytics.tsx b/src/Analytics.tsx
index 212bfd3757..fc4664039f 100644
--- a/src/Analytics.tsx
+++ b/src/Analytics.tsx
@@ -17,7 +17,7 @@ limitations under the License.
 
 import React from 'react';
 
-import {getCurrentLanguage, _t, _td, IVariables} from './languageHandler';
+import { getCurrentLanguage, _t, _td, IVariables } from './languageHandler';
 import PlatformPeg from './PlatformPeg';
 import SdkConfig from './SdkConfig';
 import Modal from './Modal';
@@ -270,7 +270,7 @@ export class Analytics {
         localStorage.removeItem(LAST_VISIT_TS_KEY);
     }
 
-    private async _track(data: IData) {
+    private async track(data: IData) {
         if (this.disabled) return;
 
         const now = new Date();
@@ -304,7 +304,7 @@ export class Analytics {
     }
 
     public ping() {
-        this._track({
+        this.track({
             ping: "1",
         });
         localStorage.setItem(LAST_VISIT_TS_KEY, String(new Date().getTime())); // update last visit ts
@@ -324,14 +324,14 @@ export class Analytics {
             // But continue anyway because we still want to track the change
         }
 
-        this._track({
+        this.track({
             gt_ms: String(generationTimeMs),
         });
     }
 
     public trackEvent(category: string, action: string, name?: string, value?: string) {
         if (this.disabled) return;
-        this._track({
+        this.track({
             e_c: category,
             e_a: action,
             e_n: name,
@@ -390,21 +390,22 @@ export class Analytics {
             { expl: _td('Your device resolution'), value: resolution },
         ];
 
+        // FIXME: Using an import will result in test failures
         const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
         Modal.createTrackedDialog('Analytics Details', '', ErrorDialog, {
             title: _t('Analytics'),
             description: 
-
{_t('The information being sent to us to help make %(brand)s better includes:', { +
{ _t('The information being sent to us to help make %(brand)s better includes:', { brand: SdkConfig.get().brand, - })}
+ }) }
{ rows.map((row) => - + ) } { row[1] !== undefined && } ) } { otherVariables.map((item, index) => diff --git a/src/AsyncWrapper.js b/src/AsyncWrapper.tsx similarity index 61% rename from src/AsyncWrapper.js rename to src/AsyncWrapper.tsx index 359828b312..68e33e02fe 100644 --- a/src/AsyncWrapper.js +++ b/src/AsyncWrapper.tsx @@ -1,6 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2015-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. @@ -15,52 +14,63 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { ComponentType } from "react"; + import * as sdk from './index'; -import PropTypes from 'prop-types'; import { _t } from './languageHandler'; +import { IDialogProps } from "./components/views/dialogs/IDialogProps"; + +import { logger } from "matrix-js-sdk/src/logger"; + +type AsyncImport = { default: T }; + +interface IProps extends IDialogProps { + // A promise which resolves with the real component + prom: Promise>; +} + +interface IState { + component?: ComponentType; + error?: Error; +} /** * Wrap an asynchronous loader function with a react component which shows a * spinner until the real component loads. */ -export default class AsyncWrapper extends React.Component { - static propTypes = { - /** A promise which resolves with the real component - */ - prom: PropTypes.object.isRequired, - }; +export default class AsyncWrapper extends React.Component { + private unmounted = false; - state = { + public state = { component: null, error: null, }; componentDidMount() { - this._unmounted = false; // XXX: temporary logging to try to diagnose // https://github.com/vector-im/element-web/issues/3148 - console.log('Starting load of AsyncWrapper for modal'); + logger.log('Starting load of AsyncWrapper for modal'); this.props.prom.then((result) => { - if (this._unmounted) { - return; - } + if (this.unmounted) return; + // Take the 'default' member if it's there, then we support // passing in just an import()ed module, since ES6 async import // always returns a module *namespace*. - const component = result.default ? result.default : result; - this.setState({component}); + const component = (result as AsyncImport).default + ? (result as AsyncImport).default + : result as ComponentType; + this.setState({ component }); }).catch((e) => { console.warn('AsyncWrapper promise failed', e); - this.setState({error: e}); + this.setState({ error: e }); }); } componentWillUnmount() { - this._unmounted = true; + this.unmounted = true; } - _onWrapperCancelClick = () => { + private onWrapperCancelClick = () => { this.props.onFinished(false); }; @@ -69,14 +79,13 @@ export default class AsyncWrapper extends React.Component { const Component = this.state.component; return ; } else if (this.state.error) { + // FIXME: Using an import will result in test failures const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return - {_t("Unable to load! Check your network connectivity and try again.")} + return + { _t("Unable to load! Check your network connectivity and try again.") } ; diff --git a/src/Avatar.ts b/src/Avatar.ts index e6b47f8a7f..93109a470e 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -14,18 +14,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {RoomMember} from "matrix-js-sdk/src/models/room-member"; -import {User} from "matrix-js-sdk/src/models/user"; -import {Room} from "matrix-js-sdk/src/models/room"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { User } from "matrix-js-sdk/src/models/user"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { ResizeMethod } from "matrix-js-sdk/src/@types/partials"; +import { split } from "lodash"; import DMRoomMap from './utils/DMRoomMap'; -import {mediaFromMxc} from "./customisations/Media"; -import SettingsStore from "./settings/SettingsStore"; - -export type ResizeMethod = "crop" | "scale"; +import { mediaFromMxc } from "./customisations/Media"; +import SpaceStore from "./stores/SpaceStore"; // Not to be used for BaseAvatar urls as that has similar default avatar fallback already -export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) { +export function avatarUrlForMember( + member: RoomMember, + width: number, + height: number, + resizeMethod: ResizeMethod, +): string { let url: string; if (member?.getMxcAvatarUrl()) { url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod); @@ -39,7 +44,12 @@ export function avatarUrlForMember(member: RoomMember, width: number, height: nu return url; } -export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) { +export function avatarUrlForUser( + user: Pick, + width: number, + height: number, + resizeMethod?: ResizeMethod, +): string | null { if (!user.avatarUrl) return null; return mediaFromMxc(user.avatarUrl).getThumbnailOfSourceHttp(width, height, resizeMethod); } @@ -113,27 +123,13 @@ export function getInitialLetter(name: string): string { return undefined; } - let idx = 0; const initial = name[0]; if ((initial === '@' || initial === '#' || initial === '+') && name[1]) { - idx++; + name = name.substring(1); } - // string.codePointAt(0) would do this, but that isn't supported by - // some browsers (notably PhantomJS). - let chars = 1; - const first = name.charCodeAt(idx); - - // check if it’s the start of a surrogate pair - if (first >= 0xD800 && first <= 0xDBFF && name[idx+1]) { - const second = name.charCodeAt(idx+1); - if (second >= 0xDC00 && second <= 0xDFFF) { - chars++; - } - } - - const firstChar = name.substring(idx, idx+chars); - return firstChar.toUpperCase(); + // rely on the grapheme cluster splitter in lodash so that we don't break apart compound emojis + return split(name, "", 1)[0].toUpperCase(); } export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) { @@ -144,7 +140,7 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi } // space rooms cannot be DMs so skip the rest - if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return null; + if (SpaceStore.spacesEnabled && room.isSpaceRoom()) return null; // If the room is not a DM don't fallback to a member avatar if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) return null; diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index 5483ea6874..5b4b15cc67 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -17,16 +17,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClient} from "matrix-js-sdk/src/client"; -import {encodeUnpaddedBase64} from "matrix-js-sdk/src/crypto/olmlib"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { encodeUnpaddedBase64 } from "matrix-js-sdk/src/crypto/olmlib"; import dis from './dispatcher/dispatcher'; import BaseEventIndexManager from './indexing/BaseEventIndexManager'; -import {ActionPayload} from "./dispatcher/payloads"; -import {CheckUpdatesPayload} from "./dispatcher/payloads/CheckUpdatesPayload"; -import {Action} from "./dispatcher/actions"; -import {hideToast as hideUpdateToast} from "./toasts/UpdateToast"; -import {MatrixClientPeg} from "./MatrixClientPeg"; -import {idbLoad, idbSave, idbDelete} from "./utils/StorageManager"; +import { ActionPayload } from "./dispatcher/payloads"; +import { CheckUpdatesPayload } from "./dispatcher/payloads/CheckUpdatesPayload"; +import { Action } from "./dispatcher/actions"; +import { hideToast as hideUpdateToast } from "./toasts/UpdateToast"; +import { MatrixClientPeg } from "./MatrixClientPeg"; +import { idbLoad, idbSave, idbDelete } from "./utils/StorageManager"; export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url"; export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url"; @@ -335,7 +335,7 @@ export default abstract class BasePlatform { try { const key = await crypto.subtle.decrypt( - {name: "AES-GCM", iv: data.iv, additionalData}, data.cryptoKey, + { name: "AES-GCM", iv: data.iv, additionalData }, data.cryptoKey, data.encrypted, ); return encodeUnpaddedBase64(key); @@ -348,7 +348,7 @@ export default abstract class BasePlatform { /** * Create and store a pickle key for encrypting libolm objects. * @param {string} userId the user ID for the user that the pickle key is for. - * @param {string} userId the device ID that the pickle key is for. + * @param {string} deviceId the device ID that the pickle key is for. * @returns {string|null} the pickle key, or null if the platform does not * support storing pickle keys. */ @@ -360,7 +360,7 @@ export default abstract class BasePlatform { const randomArray = new Uint8Array(32); crypto.getRandomValues(randomArray); const cryptoKey = await crypto.subtle.generateKey( - {name: "AES-GCM", length: 256}, false, ["encrypt", "decrypt"], + { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"], ); const iv = new Uint8Array(32); crypto.getRandomValues(iv); @@ -375,11 +375,11 @@ export default abstract class BasePlatform { } const encrypted = await crypto.subtle.encrypt( - {name: "AES-GCM", iv, additionalData}, cryptoKey, randomArray, + { name: "AES-GCM", iv, additionalData }, cryptoKey, randomArray, ); try { - await idbSave("pickleKey", [userId, deviceId], {encrypted, iv, cryptoKey}); + await idbSave("pickleKey", [userId, deviceId], { encrypted, iv, cryptoKey }); } catch (e) { return null; } diff --git a/src/BlurhashEncoder.ts b/src/BlurhashEncoder.ts new file mode 100644 index 0000000000..2aee370fe9 --- /dev/null +++ b/src/BlurhashEncoder.ts @@ -0,0 +1,60 @@ +/* +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 { defer, IDeferred } from "matrix-js-sdk/src/utils"; + +// @ts-ignore - `.ts` is needed here to make TS happy +import BlurhashWorker from "./workers/blurhash.worker.ts"; + +interface IBlurhashWorkerResponse { + seq: number; + blurhash: string; +} + +export class BlurhashEncoder { + private static internalInstance = new BlurhashEncoder(); + + public static get instance(): BlurhashEncoder { + return BlurhashEncoder.internalInstance; + } + + private readonly worker: Worker; + private seq = 0; + private pendingDeferredMap = new Map>(); + + constructor() { + this.worker = new BlurhashWorker(); + this.worker.onmessage = this.onMessage; + } + + private onMessage = (ev: MessageEvent) => { + const { seq, blurhash } = ev.data; + const deferred = this.pendingDeferredMap.get(seq); + if (deferred) { + this.pendingDeferredMap.delete(seq); + deferred.resolve(blurhash); + } + }; + + public getBlurhash(imageData: ImageData): Promise { + const seq = this.seq++; + const deferred = defer(); + this.pendingDeferredMap.set(seq, deferred); + this.worker.postMessage({ seq, imageData }); + return deferred.promise; + } +} + diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 0d87451b5f..bc9ccc4bcd 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -1,7 +1,8 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017, 2018 New Vector Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -55,19 +56,17 @@ limitations under the License. import React from 'react'; -import {MatrixClientPeg} from './MatrixClientPeg'; -import PlatformPeg from './PlatformPeg'; +import { MatrixClientPeg } from './MatrixClientPeg'; import Modal from './Modal'; import { _t } from './languageHandler'; import dis from './dispatcher/dispatcher'; import WidgetUtils from './utils/WidgetUtils'; -import WidgetEchoStore from './stores/WidgetEchoStore'; import SettingsStore from './settings/SettingsStore'; -import {Jitsi} from "./widgets/Jitsi"; -import {WidgetType} from "./widgets/WidgetType"; -import {SettingLevel} from "./settings/SettingLevel"; +import { Jitsi } from "./widgets/Jitsi"; +import { WidgetType } from "./widgets/WidgetType"; +import { SettingLevel } from "./settings/SettingLevel"; import { ActionPayload } from "./dispatcher/payloads"; -import {base32} from "rfc4648"; +import { base32 } from "rfc4648"; import QuestionDialog from "./components/views/dialogs/QuestionDialog"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; @@ -77,10 +76,9 @@ import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions"; import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty, CallType } from "matrix-js-sdk/src/webrtc/call"; import Analytics from './Analytics'; import CountlyAnalytics from "./CountlyAnalytics"; -import {UIFeature} from "./settings/UIFeature"; +import { UIFeature } from "./settings/UIFeature"; import { CallError } from "matrix-js-sdk/src/webrtc/call"; import { logger } from 'matrix-js-sdk/src/logger'; -import DesktopCapturerSourcePicker from "./components/views/elements/DesktopCapturerSourcePicker" import { Action } from './dispatcher/actions'; import VoipUserMapper from './VoipUserMapper'; import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid'; @@ -88,6 +86,12 @@ import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/ import EventEmitter from 'events'; import SdkConfig from './SdkConfig'; import { ensureDMExists, findDMForUser } from './createRoom'; +import { RuleId, TweakName, Tweaks } from "matrix-js-sdk/src/@types/PushRules"; +import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor'; +import { WidgetLayoutStore, Container } from './stores/widgets/WidgetLayoutStore'; +import { getIncomingCallToastKey } from './toasts/IncomingCallToast'; +import ToastStore from './stores/ToastStore'; +import IncomingCallToast from "./toasts/IncomingCallToast"; export const PROTOCOL_PSTN = 'm.protocol.pstn'; export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn'; @@ -124,24 +128,20 @@ interface ThirdpartyLookupResponseFields { } interface ThirdpartyLookupResponse { - userid: string, - protocol: string, - fields: ThirdpartyLookupResponseFields, + userid: string; + protocol: string; + fields: ThirdpartyLookupResponseFields; } -// Unlike 'CallType' in js-sdk, this one includes screen sharing -// (because a screen sharing call is only a screen sharing call to the caller, -// to the callee it's just a video call, at least as far as the current impl -// is concerned). export enum PlaceCallType { Voice = 'voice', Video = 'video', - ScreenSharing = 'screensharing', } export enum CallHandlerEvent { CallsChanged = "calls_changed", CallChangeRoom = "call_change_room", + SilencedCallsChanged = "silenced_calls_changed", } export default class CallHandler extends EventEmitter { @@ -154,7 +154,7 @@ export default class CallHandler extends EventEmitter { private supportsPstnProtocol = null; private pstnSupportPrefixed = null; // True if the server only support the prefixed pstn protocol private supportsSipNativeVirtual = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native - private pstnSupportCheckTimer: NodeJS.Timeout; // number actually because we're in the browser + private pstnSupportCheckTimer: number; // For rooms we've been invited to, true if they're from virtual user, false if we've checked and they aren't. private invitedRoomsAreVirtual = new Map(); private invitedRoomCheckInProgress = false; @@ -164,9 +164,11 @@ export default class CallHandler extends EventEmitter { // do the async lookup when we get new information and then store these mappings here private assertedIdentityNativeUsers = new Map(); + private silencedCalls = new Set(); // callIds + static sharedInstance() { if (!window.mxCallHandler) { - window.mxCallHandler = new CallHandler() + window.mxCallHandler = new CallHandler(); } return window.mxCallHandler; @@ -185,7 +187,7 @@ export default class CallHandler extends EventEmitter { const nativeUser = this.assertedIdentityNativeUsers[call.callId]; if (nativeUser) { const room = findDMForUser(MatrixClientPeg.get(), nativeUser); - if (room) return room.roomId + if (room) return room.roomId; } } @@ -224,6 +226,41 @@ export default class CallHandler extends EventEmitter { } } + public silenceCall(callId: string) { + this.silencedCalls.add(callId); + this.emit(CallHandlerEvent.SilencedCallsChanged, this.silencedCalls); + + // Don't pause audio if we have calls which are still ringing + if (this.areAnyCallsUnsilenced()) return; + this.pause(AudioID.Ring); + } + + public unSilenceCall(callId: string) { + this.silencedCalls.delete(callId); + this.emit(CallHandlerEvent.SilencedCallsChanged, this.silencedCalls); + this.play(AudioID.Ring); + } + + public isCallSilenced(callId: string): boolean { + return this.silencedCalls.has(callId); + } + + /** + * Returns true if there is at least one unsilenced call + * @returns {boolean} + */ + private areAnyCallsUnsilenced(): boolean { + for (const call of this.calls.values()) { + if ( + call.state === CallState.Ringing && + !this.isCallSilenced(call.callId) + ) { + return true; + } + } + return false; + } + private async checkProtocols(maxTries) { try { const protocols = await MatrixClientPeg.get().getThirdpartyProtocols(); @@ -238,7 +275,7 @@ export default class CallHandler extends EventEmitter { this.supportsPstnProtocol = null; } - dis.dispatch({action: Action.PstnSupportUpdated}); + dis.dispatch({ action: Action.PstnSupportUpdated }); if (protocols[PROTOCOL_SIP_NATIVE] !== undefined && protocols[PROTOCOL_SIP_VIRTUAL] !== undefined) { this.supportsSipNativeVirtual = Boolean( @@ -246,12 +283,12 @@ export default class CallHandler extends EventEmitter { ); } - dis.dispatch({action: Action.VirtualRoomSupportUpdated}); + dis.dispatch({ action: Action.VirtualRoomSupportUpdated }); } catch (e) { if (maxTries === 1) { - console.log("Failed to check for protocol support and no retries remain: assuming no support", e); + logger.log("Failed to check for protocol support and no retries remain: assuming no support", e); } else { - console.log("Failed to check for protocol support: will retry", e); + logger.log("Failed to check for protocol support: will retry", e); this.pstnSupportCheckTimer = setTimeout(() => { this.checkProtocols(maxTries - 1); }, 10000); @@ -299,6 +336,13 @@ export default class CallHandler extends EventEmitter { action: 'incoming_call', call: call, }, true); + }; + + public getCallById(callId: string): MatrixCall { + for (const call of this.calls.values()) { + if (call.callId === callId) return call; + } + return null; } getCallForRoom(roomId: string): MatrixCall { @@ -355,7 +399,7 @@ export default class CallHandler extends EventEmitter { // or chrome doesn't think so and is denying the request. Not sure what // we can really do here... // https://github.com/vector-im/element-web/issues/7657 - console.log("Unable to play audio clip", e); + logger.log("Unable to play audio clip", e); } }; if (this.audioPromises.has(audioId)) { @@ -394,7 +438,7 @@ export default class CallHandler extends EventEmitter { } private setCallListeners(call: MatrixCall) { - let mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); + let mappedRoomId = this.roomIdForCall(call); call.on(CallEvent.Error, (err: CallError) => { if (!this.matchesCallForThisRoom(call)) return; @@ -428,78 +472,12 @@ export default class CallHandler extends EventEmitter { this.removeCallForRoom(mappedRoomId); }); call.on(CallEvent.State, (newState: CallState, oldState: CallState) => { - if (!this.matchesCallForThisRoom(call)) return; - - this.setCallState(call, newState); - - switch (oldState) { - case CallState.Ringing: - this.pause(AudioID.Ring); - break; - case CallState.InviteSent: - this.pause(AudioID.Ringback); - break; - } - - switch (newState) { - case CallState.Ringing: - this.play(AudioID.Ring); - break; - case CallState.InviteSent: - this.play(AudioID.Ringback); - break; - case CallState.Ended: - { - Analytics.trackEvent('voip', 'callEnded', 'hangupReason', call.hangupReason); - this.removeCallForRoom(mappedRoomId); - if (oldState === CallState.InviteSent && ( - call.hangupParty === CallParty.Remote || - (call.hangupParty === CallParty.Local && call.hangupReason === CallErrorCode.InviteTimeout) - )) { - this.play(AudioID.Busy); - let title; - let description; - if (call.hangupReason === CallErrorCode.UserHangup) { - title = _t("Call Declined"); - description = _t("The other party declined the call."); - } else if (call.hangupReason === CallErrorCode.UserBusy) { - title = _t("User Busy"); - description = _t("The user you called is busy."); - } else if (call.hangupReason === CallErrorCode.InviteTimeout) { - title = _t("Call Failed"); - // XXX: full stop appended as some relic here, but these - // strings need proper input from design anyway, so let's - // not change this string until we have a proper one. - description = _t('The remote side failed to pick up') + '.'; - } else { - title = _t("Call Failed"); - description = _t("The call could not be established"); - } - - Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, { - title, description, - }); - } else if ( - call.hangupReason === CallErrorCode.AnsweredElsewhere && oldState === CallState.Connecting - ) { - Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, { - title: _t("Answered Elsewhere"), - description: _t("The call was answered on another device."), - }); - } else if (oldState !== CallState.Fledgling && oldState !== CallState.Ringing) { - // don't play the end-call sound for calls that never got off the ground - this.play(AudioID.CallEnd); - } - - this.logCallStats(call, mappedRoomId); - break; - } - } + this.onCallStateChanged(newState, oldState, call); }); call.on(CallEvent.Replaced, (newCall: MatrixCall) => { if (!this.matchesCallForThisRoom(call)) return; - console.log(`Call ID ${call.callId} is being replaced by call ID ${newCall.callId}`); + logger.log(`Call ID ${call.callId} is being replaced by call ID ${newCall.callId}`); if (call.state === CallState.Ringing) { this.pause(AudioID.Ring); @@ -507,15 +485,15 @@ export default class CallHandler extends EventEmitter { this.pause(AudioID.Ringback); } - this.calls.set(mappedRoomId, newCall); - this.emit(CallHandlerEvent.CallsChanged, this.calls); + this.removeCallForRoom(mappedRoomId); + this.addCallForRoom(mappedRoomId, newCall); this.setCallListeners(newCall); this.setCallState(newCall, newCall.state); }); call.on(CallEvent.AssertedIdentityChanged, async () => { if (!this.matchesCallForThisRoom(call)) return; - console.log(`Call ID ${call.callId} got new asserted identity:`, call.getRemoteAssertedIdentity()); + logger.log(`Call ID ${call.callId} got new asserted identity:`, call.getRemoteAssertedIdentity()); const newAssertedIdentity = call.getRemoteAssertedIdentity().id; let newNativeAssertedIdentity = newAssertedIdentity; @@ -525,7 +503,7 @@ export default class CallHandler extends EventEmitter { newNativeAssertedIdentity = response[0].userid; } } - console.log(`Asserted identity ${newAssertedIdentity} mapped to ${newNativeAssertedIdentity}`); + logger.log(`Asserted identity ${newAssertedIdentity} mapped to ${newNativeAssertedIdentity}`); if (newNativeAssertedIdentity) { this.assertedIdentityNativeUsers[call.callId] = newNativeAssertedIdentity; @@ -538,17 +516,100 @@ export default class CallHandler extends EventEmitter { await ensureDMExists(MatrixClientPeg.get(), newNativeAssertedIdentity); const newMappedRoomId = this.roomIdForCall(call); - console.log(`Old room ID: ${mappedRoomId}, new room ID: ${newMappedRoomId}`); + logger.log(`Old room ID: ${mappedRoomId}, new room ID: ${newMappedRoomId}`); if (newMappedRoomId !== mappedRoomId) { this.removeCallForRoom(mappedRoomId); mappedRoomId = newMappedRoomId; - this.calls.set(mappedRoomId, call); - this.emit(CallHandlerEvent.CallChangeRoom, call); + logger.log("Moving call to room " + mappedRoomId); + this.addCallForRoom(mappedRoomId, call, true); } } }); } + private onCallStateChanged = (newState: CallState, oldState: CallState, call: MatrixCall): void => { + if (!this.matchesCallForThisRoom(call)) return; + + const mappedRoomId = this.roomIdForCall(call); + this.setCallState(call, newState); + + switch (oldState) { + case CallState.Ringing: + this.pause(AudioID.Ring); + break; + case CallState.InviteSent: + this.pause(AudioID.Ringback); + break; + } + + if (newState !== CallState.Ringing) { + this.silencedCalls.delete(call.callId); + } + + switch (newState) { + case CallState.Ringing: { + const incomingCallPushRule = ( + new PushProcessor(MatrixClientPeg.get()).getPushRuleById(RuleId.IncomingCall) + ); + const pushRuleEnabled = incomingCallPushRule?.enabled; + const tweakSetToRing = incomingCallPushRule?.actions.some((action: Tweaks) => ( + action.set_tweak === TweakName.Sound && + action.value === "ring" + )); + + if (pushRuleEnabled && tweakSetToRing) { + this.play(AudioID.Ring); + } else { + this.silenceCall(call.callId); + } + break; + } + case CallState.InviteSent: { + this.play(AudioID.Ringback); + break; + } + case CallState.Ended: { + const hangupReason = call.hangupReason; + Analytics.trackEvent('voip', 'callEnded', 'hangupReason', hangupReason); + this.removeCallForRoom(mappedRoomId); + if (oldState === CallState.InviteSent && call.hangupParty === CallParty.Remote) { + this.play(AudioID.Busy); + + // Don't show a modal when we got rejected/the call was hung up + if (!hangupReason || [CallErrorCode.UserHangup, "user hangup"].includes(hangupReason)) break; + + let title; + let description; + // TODO: We should either do away with these or figure out a copy for each code (expect user_hangup...) + if (call.hangupReason === CallErrorCode.UserBusy) { + title = _t("User Busy"); + description = _t("The user you called is busy."); + } else { + title = _t("Call Failed"); + description = _t("The call could not be established"); + } + + Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, { + title, description, + }); + } else if ( + hangupReason === CallErrorCode.AnsweredElsewhere && oldState === CallState.Connecting + ) { + Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, { + title: _t("Answered Elsewhere"), + description: _t("The call was answered on another device."), + }); + } else if (oldState !== CallState.Fledgling && oldState !== CallState.Ringing) { + // don't play the end-call sound for calls that never got off the ground + this.play(AudioID.CallEnd); + } + + this.logCallStats(call, mappedRoomId); + break; + } + } + }; + private async logCallStats(call: MatrixCall, mappedRoomId: string) { const stats = await call.getCurrentCallStats(); logger.debug( @@ -595,10 +656,23 @@ export default class CallHandler extends EventEmitter { private setCallState(call: MatrixCall, status: CallState) { const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); - console.log( + logger.log( `Call state in ${mappedRoomId} changed to ${status}`, ); + const toastKey = getIncomingCallToastKey(call.callId); + if (status === CallState.Ringing) { + ToastStore.sharedInstance().addOrReplaceToast({ + key: toastKey, + priority: 100, + component: IncomingCallToast, + bodyClassName: "mx_IncomingCallToast", + props: { call }, + }); + } else { + ToastStore.sharedInstance().dismissToast(toastKey); + } + dis.dispatch({ action: 'call_state', room_id: mappedRoomId, @@ -607,29 +681,30 @@ export default class CallHandler extends EventEmitter { } private removeCallForRoom(roomId: string) { + logger.log("Removing call for room ", roomId); this.calls.delete(roomId); this.emit(CallHandlerEvent.CallsChanged, this.calls); } private showICEFallbackPrompt() { const cli = MatrixClientPeg.get(); - const code = sub => {sub}; + const code = sub => { sub }; Modal.createTrackedDialog('No TURN servers', '', QuestionDialog, { title: _t("Call failed due to misconfigured server"), description:
-

{_t( +

{ _t( "Please ask the administrator of your homeserver " + "(%(homeserverDomain)s) to configure a TURN server in " + "order for calls to work reliably.", { homeserverDomain: cli.getDomain() }, { code }, - )}

-

{_t( + ) }

+

{ _t( "Alternatively, you can try to use the public server at " + "turn.matrix.org, but this will not be as reliable, and " + "it will share your IP address with that server. You can also manage " + "this in Settings.", null, { code }, - )}

+ ) }

, button: _t('Try using turn.matrix.org'), cancelButton: _t('OK'), @@ -647,19 +722,19 @@ export default class CallHandler extends EventEmitter { if (call.type === CallType.Voice) { title = _t("Unable to access microphone"); description =
- {_t( + { _t( "Call failed because microphone could not be accessed. " + "Check that a microphone is plugged in and set up correctly.", - )} + ) }
; } else if (call.type === CallType.Video) { title = _t("Unable to access webcam / microphone"); description =
- {_t("Call failed because webcam or microphone could not be accessed. Check that:")} + { _t("Call failed because webcam or microphone could not be accessed. Check that:") }
    -
  • {_t("A microphone and webcam are plugged in and set up correctly")}
  • -
  • {_t("Permission is granted to use the webcam")}
  • -
  • {_t("No other application is using the webcam")}
  • +
  • { _t("A microphone and webcam are plugged in and set up correctly") }
  • +
  • { _t("Permission is granted to use the webcam") }
  • +
  • { _t("No other application is using the webcam") }
; } @@ -677,11 +752,18 @@ export default class CallHandler extends EventEmitter { logger.debug("Mapped real room " + roomId + " to room ID " + mappedRoomId); const timeUntilTurnCresExpire = MatrixClientPeg.get().getTurnServersExpiry() - Date.now(); - console.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms"); + logger.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms"); const call = MatrixClientPeg.get().createCall(mappedRoomId); - this.calls.set(roomId, call); - this.emit(CallHandlerEvent.CallsChanged, this.calls); + try { + this.addCallForRoom(roomId, call); + } catch (e) { + Modal.createTrackedDialog('Call Handler', 'Existing Call with user', ErrorDialog, { + title: _t('Already in call'), + description: _t("You're already in a call with this person."), + }); + return; + } if (transferee) { this.transferees[call.callId] = transferee; } @@ -694,25 +776,6 @@ export default class CallHandler extends EventEmitter { call.placeVoiceCall(); } else if (type === 'video') { call.placeVideoCall(); - } else if (type === PlaceCallType.ScreenSharing) { - const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString(); - if (screenCapErrorString) { - this.removeCallForRoom(roomId); - console.log("Can't capture screen: " + screenCapErrorString); - Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, { - title: _t('Unable to capture screen'), - description: screenCapErrorString, - }); - return; - } - - call.placeScreenSharingCall( - async (): Promise => { - const {finished} = Modal.createDialog(DesktopCapturerSourcePicker); - const [source] = await finished; - return source; - }, - ); } else { console.error("Unknown conf call type: " + type); } @@ -752,13 +815,8 @@ export default class CallHandler extends EventEmitter { return; } - if (this.getCallForRoom(room.roomId)) { - Modal.createTrackedDialog('Call Handler', 'Existing Call with user', ErrorDialog, { - title: _t('Already in call'), - description: _t("You're already in a call with this person."), - }); - return; - } + // We leave the check for whether there's already a call in this room until later, + // otherwise it can race. const members = room.getJoinedMembers(); if (members.length <= 1) { @@ -804,7 +862,7 @@ export default class CallHandler extends EventEmitter { const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); if (this.getCallForRoom(mappedRoomId)) { - console.log( + logger.log( "Got incoming call for room " + mappedRoomId + " but there's already a call for this room: ignoring", ); @@ -812,9 +870,11 @@ export default class CallHandler extends EventEmitter { } Analytics.trackEvent('voip', 'receiveCall', 'type', call.type); - this.calls.set(mappedRoomId, call) - this.emit(CallHandlerEvent.CallsChanged, this.calls); + + this.addCallForRoom(mappedRoomId, call); this.setCallListeners(call); + // Explicitly handle first state change + this.onCallStateChanged(call.state, null, call); // get ready to send encrypted events in the room, so if the user does answer // the call, we'll be ready to send. NB. This is the protocol-level room ID not @@ -825,6 +885,8 @@ export default class CallHandler extends EventEmitter { break; case 'hangup': case 'reject': + this.stopRingingIfPossible(this.calls.get(payload.room_id).callId); + if (!this.calls.get(payload.room_id)) { return; // no call to hangup } @@ -837,11 +899,15 @@ export default class CallHandler extends EventEmitter { // the hangup event away) break; case 'hangup_all': + this.stopRingingIfPossible(this.calls.get(payload.room_id).callId); + for (const call of this.calls.values()) { call.hangup(CallErrorCode.UserHangup, false); } break; case 'answer': { + this.stopRingingIfPossible(this.calls.get(payload.room_id).callId); + if (!this.calls.has(payload.room_id)) { return; // no call to answer } @@ -867,7 +933,19 @@ export default class CallHandler extends EventEmitter { case Action.DialNumber: this.dialNumber(payload.number); break; + case Action.TransferCallToMatrixID: + this.startTransferToMatrixID(payload.call, payload.destination, payload.consultFirst); + break; + case Action.TransferCallToPhoneNumber: + this.startTransferToPhoneNumber(payload.call, payload.destination, payload.consultFirst); + break; } + }; + + private stopRingingIfPossible(callId: string): void { + this.silencedCalls.delete(callId); + if (this.areAnyCallsUnsilenced()) return; + this.pause(AudioID.Ring); } private async dialNumber(number: string) { @@ -888,7 +966,7 @@ export default class CallHandler extends EventEmitter { const nativeLookupResults = await this.sipNativeLookup(userId); const lookupSuccess = nativeLookupResults.length > 0 && nativeLookupResults[0].fields.lookup_success; nativeUserId = lookupSuccess ? nativeLookupResults[0].userid : userId; - console.log("Looked up " + number + " to " + userId + " and mapped to native user " + nativeUserId); + logger.log("Looked up " + number + " to " + userId + " and mapped to native user " + nativeUserId); } else { nativeUserId = userId; } @@ -899,6 +977,50 @@ export default class CallHandler extends EventEmitter { action: 'view_room', room_id: roomId, }); + + await this.placeCall(roomId, PlaceCallType.Voice, null); + } + + private async startTransferToPhoneNumber(call: MatrixCall, destination: string, consultFirst: boolean) { + const results = await this.pstnLookup(destination); + if (!results || results.length === 0 || !results[0].userid) { + Modal.createTrackedDialog('', '', ErrorDialog, { + title: _t("Unable to transfer call"), + description: _t("There was an error looking up the phone number"), + }); + return; + } + + await this.startTransferToMatrixID(call, results[0].userid, consultFirst); + } + + private async startTransferToMatrixID(call: MatrixCall, destination: string, consultFirst: boolean) { + if (consultFirst) { + const dmRoomId = await ensureDMExists(MatrixClientPeg.get(), destination); + + dis.dispatch({ + action: 'place_call', + type: call.type, + room_id: dmRoomId, + transferee: call, + }); + dis.dispatch({ + action: 'view_room', + room_id: dmRoomId, + should_peek: false, + joining: false, + }); + } else { + try { + await call.transfer(destination); + } catch (e) { + logger.log("Failed to transfer call", e); + Modal.createTrackedDialog('Failed to transfer call', '', ErrorDialog, { + title: _t('Transfer Failed'), + description: _t('Failed to transfer call'), + }); + } + } } setActiveCallRoomId(activeCallRoomId: string) { @@ -936,14 +1058,10 @@ export default class CallHandler extends EventEmitter { // prevent double clicking the call button const room = MatrixClientPeg.get().getRoom(roomId); - const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI); - const hasJitsi = currentJitsiWidgets.length > 0 - || WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI); - if (hasJitsi) { - Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, { - title: _t('Call in Progress'), - description: _t('A call is currently being placed!'), - }); + const jitsiWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.JITSI.matches(app.type)); + if (jitsiWidget) { + // If there already is a Jitsi widget pin it + WidgetLayoutStore.instance.moveToContainer(room, jitsiWidget, Container.Top); return; } @@ -962,7 +1080,7 @@ export default class CallHandler extends EventEmitter { confId = 'Jitsi' + random; } - let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl({auth: jitsiAuth}); + let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl({ auth: jitsiAuth }); // TODO: Remove URL hacks when the mobile clients eventually support v2 widgets const parsedUrl = new URL(widgetUrl); @@ -986,7 +1104,7 @@ export default class CallHandler extends EventEmitter { ); WidgetUtils.setRoomWidget(roomId, widgetId, WidgetType.JITSI, widgetUrl, 'Jitsi', widgetData).then(() => { - console.log('Jitsi widget added'); + logger.log('Jitsi widget added'); }).catch((e) => { if (e.errcode === 'M_FORBIDDEN') { Modal.createTrackedDialog('Call Failed', '', ErrorDialog, { @@ -1031,4 +1149,21 @@ export default class CallHandler extends EventEmitter { messaging.transport.send(ElementWidgetActions.HangupCall, {}); }); } + + private addCallForRoom(roomId: string, call: MatrixCall, changedRooms = false): void { + if (this.calls.has(roomId)) { + logger.log(`Couldn't add call to room ${roomId}: already have a call for this room`); + throw new Error("Already have a call for room " + roomId); + } + + logger.log("setting call for room " + roomId); + this.calls.set(roomId, call); + + // Should we always emit CallsChanged too? + if (changedRooms) { + this.emit(CallHandlerEvent.CallChangeRoom, call); + } else { + this.emit(CallHandlerEvent.CallsChanged, this.calls); + } + } } diff --git a/src/CallMediaHandler.js b/src/CallMediaHandler.js deleted file mode 100644 index 634f0bb336..0000000000 --- a/src/CallMediaHandler.js +++ /dev/null @@ -1,85 +0,0 @@ -/* - Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> - - 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 SettingsStore from "./settings/SettingsStore"; -import {SettingLevel} from "./settings/SettingLevel"; -import {setMatrixCallAudioInput, setMatrixCallVideoInput} from "matrix-js-sdk/src/matrix"; - -export default { - hasAnyLabeledDevices: async function() { - const devices = await navigator.mediaDevices.enumerateDevices(); - return devices.some(d => !!d.label); - }, - - getDevices: function() { - // Only needed for Electron atm, though should work in modern browsers - // once permission has been granted to the webapp - return navigator.mediaDevices.enumerateDevices().then(function(devices) { - const audiooutput = []; - const audioinput = []; - const videoinput = []; - - devices.forEach((device) => { - switch (device.kind) { - case 'audiooutput': audiooutput.push(device); break; - case 'audioinput': audioinput.push(device); break; - case 'videoinput': videoinput.push(device); break; - } - }); - - // console.log("Loaded WebRTC Devices", mediaDevices); - return { - audiooutput, - audioinput, - videoinput, - }; - }, (error) => { console.log('Unable to refresh WebRTC Devices: ', error); }); - }, - - loadDevices: function() { - const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); - const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); - - setMatrixCallAudioInput(audioDeviceId); - setMatrixCallVideoInput(videoDeviceId); - }, - - setAudioOutput: function(deviceId) { - SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId); - }, - - setAudioInput: function(deviceId) { - SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId); - setMatrixCallAudioInput(deviceId); - }, - - setVideoInput: function(deviceId) { - SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId); - setMatrixCallVideoInput(deviceId); - }, - - getAudioOutput: function() { - return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audiooutput"); - }, - - getAudioInput: function() { - return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audioinput"); - }, - - getVideoInput: function() { - return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_videoinput"); - }, -}; diff --git a/src/ContentMessages.tsx b/src/ContentMessages.tsx index b21829ac63..60242b373a 100644 --- a/src/ContentMessages.tsx +++ b/src/ContentMessages.tsx @@ -17,9 +17,9 @@ limitations under the License. */ import React from "react"; +import { MatrixClient } from "matrix-js-sdk/src/client"; + import dis from './dispatcher/dispatcher'; -import {MatrixClientPeg} from './MatrixClientPeg'; -import {MatrixClient} from "matrix-js-sdk/src/client"; import * as sdk from './index'; import { _t } from './languageHandler'; import Modal from './Modal'; @@ -27,9 +27,6 @@ import RoomViewStore from './stores/RoomViewStore'; import encrypt from "browser-encrypt-attachment"; import extractPngChunks from "png-chunks-extract"; import Spinner from "./components/views/elements/Spinner"; - -// Polyfill for Canvas.toBlob API using Canvas.toDataURL -import "blueimp-canvas-to-blob"; import { Action } from "./dispatcher/actions"; import CountlyAnalytics from "./CountlyAnalytics"; import { @@ -39,8 +36,13 @@ import { UploadProgressPayload, UploadStartedPayload, } from "./dispatcher/payloads/UploadPayload"; -import {IUpload} from "./models/IUpload"; -import { IImageInfo } from "matrix-js-sdk/src/@types/partials"; +import { IUpload } from "./models/IUpload"; +import { IAbortablePromise, IImageInfo } from "matrix-js-sdk/src/@types/partials"; +import { BlurhashEncoder } from "./BlurhashEncoder"; +import SettingsStore from "./settings/SettingsStore"; +import { decorateStartSendingTime, sendRoundTripMetric } from "./sendTimePerformanceMetrics"; + +import { logger } from "matrix-js-sdk/src/logger"; const MAX_WIDTH = 800; const MAX_HEIGHT = 600; @@ -49,6 +51,8 @@ const MAX_HEIGHT = 600; // 5669 px (x-axis) , 5669 px (y-axis) , per metre const PHYS_HIDPI = [0x00, 0x00, 0x16, 0x25, 0x00, 0x00, 0x16, 0x25, 0x01]; +export const BLURHASH_FIELD = "xyz.amorgan.blurhash"; // MSC2448 + export class UploadCanceledError extends Error {} type ThumbnailableElement = HTMLImageElement | HTMLVideoElement; @@ -79,14 +83,11 @@ interface IThumbnail { }; w: number; h: number; + [BLURHASH_FIELD]: string; }; thumbnail: Blob; } -interface IAbortablePromise extends Promise { - abort(): void; -} - /** * Create a thumbnail for a image DOM element. * The image will be smaller than MAX_WIDTH and MAX_HEIGHT. @@ -105,44 +106,62 @@ interface IAbortablePromise extends Promise { * @return {Promise} A promise that resolves with an object with an info key * and a thumbnail key. */ -function createThumbnail( +async function createThumbnail( element: ThumbnailableElement, inputWidth: number, inputHeight: number, mimeType: string, ): Promise { - return new Promise((resolve) => { - let targetWidth = inputWidth; - let targetHeight = inputHeight; - if (targetHeight > MAX_HEIGHT) { - targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight)); - targetHeight = MAX_HEIGHT; - } - if (targetWidth > MAX_WIDTH) { - targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth)); - targetWidth = MAX_WIDTH; - } + let targetWidth = inputWidth; + let targetHeight = inputHeight; + if (targetHeight > MAX_HEIGHT) { + targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight)); + targetHeight = MAX_HEIGHT; + } + if (targetWidth > MAX_WIDTH) { + targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth)); + targetWidth = MAX_WIDTH; + } - const canvas = document.createElement("canvas"); + let canvas: HTMLCanvasElement | OffscreenCanvas; + if (window.OffscreenCanvas) { + canvas = new window.OffscreenCanvas(targetWidth, targetHeight); + } else { + canvas = document.createElement("canvas"); canvas.width = targetWidth; canvas.height = targetHeight; - canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight); - canvas.toBlob(function(thumbnail) { - resolve({ - info: { - thumbnail_info: { - w: targetWidth, - h: targetHeight, - mimetype: thumbnail.type, - size: thumbnail.size, - }, - w: inputWidth, - h: inputHeight, - }, - thumbnail: thumbnail, - }); - }, mimeType); - }); + } + + const context = canvas.getContext("2d"); + context.drawImage(element, 0, 0, targetWidth, targetHeight); + + let thumbnailPromise: Promise; + + if (window.OffscreenCanvas) { + thumbnailPromise = (canvas as OffscreenCanvas).convertToBlob({ type: mimeType }); + } else { + thumbnailPromise = new Promise(resolve => (canvas as HTMLCanvasElement).toBlob(resolve, mimeType)); + } + + const imageData = context.getImageData(0, 0, targetWidth, targetHeight); + // thumbnailPromise and blurhash promise are being awaited concurrently + const blurhash = await BlurhashEncoder.instance.getBlurhash(imageData); + const thumbnail = await thumbnailPromise; + + return { + info: { + thumbnail_info: { + w: targetWidth, + h: targetHeight, + mimetype: thumbnail.type, + size: thumbnail.size, + }, + w: inputWidth, + h: inputHeight, + [BLURHASH_FIELD]: blurhash, + }, + thumbnail, + }; } /** @@ -191,9 +210,17 @@ async function loadImageElement(imageFile: File) { const [hidpi] = await Promise.all([parsePromise, imgPromise]); const width = hidpi ? (img.width >> 1) : img.width; const height = hidpi ? (img.height >> 1) : img.height; - return {width, height, img}; + return { width, height, img }; } +// Minimum size for image files before we generate a thumbnail for them. +const IMAGE_SIZE_THRESHOLD_THUMBNAIL = 1 << 15; // 32KB +// Minimum size improvement for image thumbnails, if both are not met then don't bother uploading thumbnail. +const IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE = 1 << 16; // 1MB +const IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT = 0.1; // 10% +// We don't apply these thresholds to video thumbnails as a poster image is always useful +// and videos tend to be much larger. + /** * Read the metadata for an image file and create and upload a thumbnail of the image. * @@ -202,27 +229,38 @@ async function loadImageElement(imageFile: File) { * @param {File} imageFile The image to read and thumbnail. * @return {Promise} A promise that resolves with the attachment info. */ -function infoForImageFile(matrixClient, roomId, imageFile) { +async function infoForImageFile(matrixClient: MatrixClient, roomId: string, imageFile: File) { let thumbnailType = "image/png"; if (imageFile.type === "image/jpeg") { thumbnailType = "image/jpeg"; } - let imageInfo; - return loadImageElement(imageFile).then((r) => { - return createThumbnail(r.img, r.width, r.height, thumbnailType); - }).then((result) => { - imageInfo = result.info; - return uploadFile(matrixClient, roomId, result.thumbnail); - }).then((result) => { - imageInfo.thumbnail_url = result.url; - imageInfo.thumbnail_file = result.file; + const imageElement = await loadImageElement(imageFile); + + const result = await createThumbnail(imageElement.img, imageElement.width, imageElement.height, thumbnailType); + const imageInfo = result.info; + + // we do all sizing checks here because we still rely on thumbnail generation for making a blurhash from. + const sizeDifference = imageFile.size - imageInfo.thumbnail_info.size; + if ( + imageFile.size <= IMAGE_SIZE_THRESHOLD_THUMBNAIL || // image is small enough already + (sizeDifference <= IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE && // thumbnail is not sufficiently smaller than original + sizeDifference <= (imageFile.size * IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT)) + ) { + delete imageInfo["thumbnail_info"]; return imageInfo; - }); + } + + const uploadResult = await uploadFile(matrixClient, roomId, result.thumbnail); + + imageInfo["thumbnail_url"] = uploadResult.url; + imageInfo["thumbnail_file"] = uploadResult.file; + return imageInfo; } /** - * Load a file into a newly created video element. + * Load a file into a newly created video element and pull some strings + * in an attempt to guarantee the first frame will be showing. * * @param {File} videoFile The file to load in an video element. * @return {Promise} A promise that resolves with the video image element. @@ -231,20 +269,25 @@ function loadVideoElement(videoFile): Promise { return new Promise((resolve, reject) => { // Load the file into an html element const video = document.createElement("video"); + video.preload = "metadata"; + video.playsInline = true; + video.muted = true; const reader = new FileReader(); reader.onload = function(ev) { - video.src = ev.target.result as string; - - // Once ready, returns its size // Wait until we have enough data to thumbnail the first frame. - video.onloadeddata = function() { + video.onloadeddata = async function() { resolve(video); + video.pause(); }; video.onerror = function(e) { reject(e); }; + + video.src = ev.target.result as string; + video.load(); + video.play(); }; reader.onerror = function(e) { reject(e); @@ -309,12 +352,12 @@ function readFileAsArrayBuffer(file: File | Blob): Promise { * If the file is unencrypted then the object will have a "url" key. * If the file is encrypted then the object will have a "file" key. */ -function uploadFile( +export function uploadFile( matrixClient: MatrixClient, roomId: string, file: File | Blob, progressHandler?: any, // TODO: Types -): Promise<{url?: string, file?: any}> { // TODO: Types +): IAbortablePromise<{url?: string, file?: any}> { // TODO: Types let canceled = false; if (matrixClient.isRoomEncrypted(roomId)) { // If the room is encrypted then encrypt the file before uploading it. @@ -345,11 +388,11 @@ function uploadFile( if (file.type) { encryptInfo.mimetype = file.type; } - return {"file": encryptInfo}; - }); - (prom as IAbortablePromise).abort = () => { + return { "file": encryptInfo }; + }) as IAbortablePromise<{ file: any }>; + prom.abort = () => { canceled = true; - if (uploadPromise) MatrixClientPeg.get().cancelUpload(uploadPromise); + if (uploadPromise) matrixClient.cancelUpload(uploadPromise); }; return prom; } else { @@ -359,11 +402,11 @@ function uploadFile( const promise1 = basePromise.then(function(url) { if (canceled) throw new UploadCanceledError(); // If the attachment isn't encrypted then include the URL directly. - return {"url": url}; - }); - (promise1 as any).abort = () => { + return { url }; + }) as IAbortablePromise<{ url: string }>; + promise1.abort = () => { canceled = true; - MatrixClientPeg.get().cancelUpload(basePromise); + matrixClient.cancelUpload(basePromise); }; return promise1; } @@ -375,11 +418,11 @@ export default class ContentMessages { sendStickerContentToRoom(url: string, roomId: string, info: IImageInfo, text: string, matrixClient: MatrixClient) { const startTime = CountlyAnalytics.getTimestamp(); - const prom = MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => { + const prom = matrixClient.sendStickerMessage(roomId, url, info, text).catch((e) => { console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e); throw e; }); - CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, {msgtype: "m.sticker"}); + CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, { msgtype: "m.sticker" }); return prom; } @@ -393,20 +436,21 @@ export default class ContentMessages { async sendContentListToRoom(files: File[], roomId: string, matrixClient: MatrixClient) { if (matrixClient.isGuest()) { - dis.dispatch({action: 'require_registration'}); + dis.dispatch({ action: 'require_registration' }); return; } const isQuoting = Boolean(RoomViewStore.getQuotingEvent()); if (isQuoting) { + // FIXME: Using an import will result in Element crashing const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - const {finished} = Modal.createTrackedDialog<[boolean]>('Upload Reply Warning', '', QuestionDialog, { + const { finished } = Modal.createTrackedDialog<[boolean]>('Upload Reply Warning', '', QuestionDialog, { title: _t('Replying With Files'), description: ( -
{_t( +
{ _t( 'At this time it is not possible to reply with a file. ' + 'Would you like to upload this file without replying?', - )}
+ ) }
), hasCancelButton: true, button: _t("Continue"), @@ -417,7 +461,7 @@ export default class ContentMessages { if (!this.mediaConfig) { // hot-path optimization to not flash a spinner if we don't need to const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner'); - await this.ensureMediaConfigFetched(); + await this.ensureMediaConfigFetched(matrixClient); modal.close(); } @@ -433,8 +477,9 @@ export default class ContentMessages { } if (tooBigFiles.length > 0) { + // FIXME: Using an import will result in Element crashing const UploadFailureDialog = sdk.getComponent("dialogs.UploadFailureDialog"); - const {finished} = Modal.createTrackedDialog<[boolean]>('Upload Failure', '', UploadFailureDialog, { + const { finished } = Modal.createTrackedDialog<[boolean]>('Upload Failure', '', UploadFailureDialog, { badFiles: tooBigFiles, totalFiles: files.length, contentMessages: this, @@ -443,7 +488,6 @@ export default class ContentMessages { if (!shouldContinue) return; } - const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog"); let uploadAll = false; // Promise to complete before sending next file into room, used for synchronisation of file-sending // to match the order the files were specified in @@ -451,7 +495,9 @@ export default class ContentMessages { for (let i = 0; i < okFiles.length; ++i) { const file = okFiles[i]; if (!uploadAll) { - const {finished} = Modal.createTrackedDialog<[boolean, boolean]>('Upload Files confirmation', + // FIXME: Using an import will result in Element crashing + const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog"); + const { finished } = Modal.createTrackedDialog<[boolean, boolean]>('Upload Files confirmation', '', UploadConfirmDialog, { file, currentIndex: i, @@ -472,7 +518,7 @@ export default class ContentMessages { return this.inprogress.filter(u => !u.canceled); } - cancelUpload(promise: Promise) { + cancelUpload(promise: Promise, matrixClient: MatrixClient) { let upload: IUpload; for (let i = 0; i < this.inprogress.length; ++i) { if (this.inprogress[i].promise === promise) { @@ -482,8 +528,8 @@ export default class ContentMessages { } if (upload) { upload.canceled = true; - MatrixClientPeg.get().cancelUpload(upload.promise); - dis.dispatch({action: Action.UploadCanceled, upload}); + matrixClient.cancelUpload(upload.promise); + dis.dispatch({ action: Action.UploadCanceled, upload }); } } @@ -497,6 +543,10 @@ export default class ContentMessages { msgtype: "", // set later }; + if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) { + decorateStartSendingTime(content); + } + // if we have a mime type for the file, add it to the message metadata if (file.type) { content.info.mimetype = file.type; @@ -529,10 +579,10 @@ export default class ContentMessages { content.msgtype = 'm.file'; resolve(); } - }); + }) as IAbortablePromise; // create temporary abort handler for before the actual upload gets passed off to js-sdk - (prom as IAbortablePromise).abort = () => { + prom.abort = () => { upload.canceled = true; }; @@ -544,15 +594,15 @@ export default class ContentMessages { promise: prom, }; this.inprogress.push(upload); - dis.dispatch({action: Action.UploadStarted, upload}); + dis.dispatch({ action: Action.UploadStarted, upload }); // Focus the composer view - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); function onProgress(ev) { upload.total = ev.total; upload.loaded = ev.loaded; - dis.dispatch({action: Action.UploadProgress, upload}); + dis.dispatch({ action: Action.UploadProgress, upload }); } let error; @@ -561,9 +611,7 @@ export default class ContentMessages { // XXX: upload.promise must be the promise that // is returned by uploadFile as it has an abort() // method hacked onto it. - upload.promise = uploadFile( - matrixClient, roomId, file, onProgress, - ); + upload.promise = uploadFile(matrixClient, roomId, file, onProgress); return upload.promise.then(function(result) { content.file = result.file; content.url = result.url; @@ -574,18 +622,24 @@ export default class ContentMessages { }).then(function() { if (upload.canceled) throw new UploadCanceledError(); const prom = matrixClient.sendMessage(roomId, content); + if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) { + prom.then(resp => { + sendRoundTripMetric(matrixClient, roomId, resp.event_id); + }); + } CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, content); return prom; }, function(err) { error = err; if (!upload.canceled) { - let desc = _t("The file '%(fileName)s' failed to upload.", {fileName: upload.fileName}); + let desc = _t("The file '%(fileName)s' failed to upload.", { fileName: upload.fileName }); if (err.http_status === 413) { desc = _t( "The file '%(fileName)s' exceeds this homeserver's size limit for uploads", - {fileName: upload.fileName}, + { fileName: upload.fileName }, ); } + // FIXME: Using an import will result in Element crashing const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Upload failed', '', ErrorDialog, { title: _t('Upload Failed'), @@ -606,10 +660,10 @@ export default class ContentMessages { if (error && error.http_status === 413) { this.mediaConfig = null; } - dis.dispatch({action: Action.UploadFailed, upload, error}); + dis.dispatch({ action: Action.UploadFailed, upload, error }); } else { - dis.dispatch({action: Action.UploadFinished, upload}); - dis.dispatch({action: 'message_sent'}); + dis.dispatch({ action: Action.UploadFinished, upload }); + dis.dispatch({ action: 'message_sent' }); } }); } @@ -623,16 +677,16 @@ export default class ContentMessages { return true; } - private ensureMediaConfigFetched() { + private ensureMediaConfigFetched(matrixClient: MatrixClient) { if (this.mediaConfig !== null) return; - console.log("[Media Config] Fetching"); - return MatrixClientPeg.get().getMediaConfig().then((config) => { - console.log("[Media Config] Fetched config:", config); + logger.log("[Media Config] Fetching"); + return matrixClient.getMediaConfig().then((config) => { + logger.log("[Media Config] Fetched config:", config); return config; }).catch(() => { // Media repo can't or won't report limits, so provide an empty object (no limits). - console.log("[Media Config] Could not fetch config, so not limiting uploads."); + logger.log("[Media Config] Could not fetch config, so not limiting uploads."); return {}; }).then((config) => { this.mediaConfig = config; diff --git a/src/CountlyAnalytics.ts b/src/CountlyAnalytics.ts index 5545ed8483..aa47d3063f 100644 --- a/src/CountlyAnalytics.ts +++ b/src/CountlyAnalytics.ts @@ -14,28 +14,24 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {randomString} from "matrix-js-sdk/src/randomstring"; +import { randomString } from "matrix-js-sdk/src/randomstring"; +import { IContent } from "matrix-js-sdk/src/models/event"; +import { sleep } from "matrix-js-sdk/src/utils"; -import {getCurrentLanguage} from './languageHandler'; +import { getCurrentLanguage } from './languageHandler'; import PlatformPeg from './PlatformPeg'; import SdkConfig from './SdkConfig'; -import {MatrixClientPeg} from "./MatrixClientPeg"; -import {sleep} from "./utils/promise"; +import { MatrixClientPeg } from "./MatrixClientPeg"; import RoomViewStore from "./stores/RoomViewStore"; import { Action } from "./dispatcher/actions"; -// polyfill textencoder if necessary -import * as TextEncodingUtf8 from 'text-encoding-utf-8'; -let TextEncoder = window.TextEncoder; -if (!TextEncoder) { - TextEncoder = TextEncodingUtf8.TextEncoder; -} - const INACTIVITY_TIME = 20; // seconds const HEARTBEAT_INTERVAL = 5_000; // ms const SESSION_UPDATE_INTERVAL = 60; // seconds const MAX_PENDING_EVENTS = 1000; +export type Rating = 1 | 2 | 3 | 4 | 5; + enum Orientation { Landscape = "landscape", Portrait = "portrait", @@ -262,7 +258,7 @@ interface ICreateRoomEvent extends IEvent { num_users: number; is_encrypted: boolean; is_public: boolean; - } + }; } interface IJoinRoomEvent extends IEvent { @@ -345,8 +341,8 @@ const getRoomStats = (roomId: string) => { "is_encrypted": cli?.isRoomEncrypted(roomId), // eslint-disable-next-line camelcase "is_public": room?.currentState.getStateEvents("m.room.join_rules", "")?.getContent()?.join_rule === "public", - } -} + }; +}; // async wrapper for regex-powered String.prototype.replace const strReplaceAsync = async (str: string, regex: RegExp, fn: (...args: string[]) => Promise) => { @@ -370,8 +366,8 @@ export default class CountlyAnalytics { private initTime = CountlyAnalytics.getTimestamp(); private firstPage = true; - private heartbeatIntervalId: NodeJS.Timeout; - private activityIntervalId: NodeJS.Timeout; + private heartbeatIntervalId: number; + private activityIntervalId: number; private trackTime = true; private lastBeat: number; private storedDuration = 0; @@ -421,7 +417,7 @@ export default class CountlyAnalytics { this.anonymous = anonymous; if (anonymous) { - await this.changeUserKey(randomString(64)) + await this.changeUserKey(randomString(64)); } else { await this.changeUserKey(await hashHex(MatrixClientPeg.get().getUserId()), true); } @@ -445,7 +441,7 @@ export default class CountlyAnalytics { await this.track("Opt-Out" ); this.endSession(); window.clearInterval(this.heartbeatIntervalId); - window.clearTimeout(this.activityIntervalId) + window.clearTimeout(this.activityIntervalId); this.baseUrl = null; // remove listeners bound in trackSessions() window.removeEventListener("beforeunload", this.endSession); @@ -457,7 +453,7 @@ export default class CountlyAnalytics { window.removeEventListener("scroll", this.onUserActivity); } - public reportFeedback(rating: 1 | 2 | 3 | 4 | 5, comment: string) { + public reportFeedback(rating: Rating, comment: string) { this.track("[CLY]_star_rating", { rating, comment }, null, {}, true); } @@ -542,7 +538,7 @@ export default class CountlyAnalytics { // sanitize the error from identifiers error = await strReplaceAsync(error, /([!@+#]).+?:[\w:.]+/g, async (substring: string, glyph: string) => { - return glyph + await hashHex(substring.substring(1)); + return glyph + (await hashHex(substring.substring(1))); }); const metrics = this.getMetrics(); @@ -669,14 +665,14 @@ export default class CountlyAnalytics { } private queue(args: Omit & Partial>) { - const {count = 1, ...rest} = args; + const { count = 1, ...rest } = args; const ev = { ...this.getTimeParams(), ...rest, count, platform: this.appPlatform, app_version: this.appVersion, - } + }; this.pendingEvents.push(ev); if (this.pendingEvents.length > MAX_PENDING_EVENTS) { @@ -687,7 +683,7 @@ export default class CountlyAnalytics { private getOrientation = (): Orientation => { return window.matchMedia("(orientation: landscape)").matches ? Orientation.Landscape - : Orientation.Portrait + : Orientation.Portrait; }; private reportOrientation = () => { @@ -756,7 +752,7 @@ export default class CountlyAnalytics { const request: Parameters[0] = { begin_session: 1, user_details: JSON.stringify(userDetails), - } + }; const metrics = this.getMetrics(); if (metrics) { @@ -780,7 +776,7 @@ export default class CountlyAnalytics { private endSession = () => { if (this.sessionStarted) { - window.removeEventListener("resize", this.reportOrientation) + window.removeEventListener("resize", this.reportOrientation); this.reportViewDuration(); this.request({ @@ -875,7 +871,7 @@ export default class CountlyAnalytics { roomId: string, isEdit: boolean, isReply: boolean, - content: {format?: string, msgtype: string}, + content: IContent, ) { if (this.disabled) return; const cli = MatrixClientPeg.get(); diff --git a/src/DateUtils.ts b/src/DateUtils.ts index e4a1175d88..221ecfeb2a 100644 --- a/src/DateUtils.ts +++ b/src/DateUtils.ts @@ -123,6 +123,31 @@ export function formatTime(date: Date, showTwelveHour = false): string { return pad(date.getHours()) + ':' + pad(date.getMinutes()); } +export function formatCallTime(delta: Date): string { + const hours = delta.getUTCHours(); + const minutes = delta.getUTCMinutes(); + const seconds = delta.getUTCSeconds(); + + let output = ""; + if (hours) output += `${hours}h `; + if (minutes || output) output += `${minutes}m `; + if (seconds || output) output += `${seconds}s`; + + return output; +} + +export function formatSeconds(inSeconds: number): string { + const hours = Math.floor(inSeconds / (60 * 60)).toFixed(0).padStart(2, '0'); + const minutes = Math.floor((inSeconds % (60 * 60)) / 60).toFixed(0).padStart(2, '0'); + const seconds = Math.floor(((inSeconds % (60 * 60)) % 60)).toFixed(0).padStart(2, '0'); + + let output = ""; + if (hours !== "00") output += `${hours}:`; + output += `${minutes}:${seconds}`; + + return output; +} + const MILLIS_IN_DAY = 86400000; export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean { if (!nextEventDate || !prevEventDate) { @@ -136,3 +161,20 @@ export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): bo // Compare weekdays return prevEventDate.getDay() !== nextEventDate.getDay(); } + +export function formatFullDateNoDay(date: Date) { + return _t("%(date)s at %(time)s", { + date: date.toLocaleDateString().replace(/\//g, '-'), + time: date.toLocaleTimeString().replace(/:/g, '-'), + }); +} + +export function formatFullDateNoDayNoTime(date: Date) { + return ( + date.getFullYear() + + "/" + + pad(date.getMonth() + 1) + + "/" + + pad(date.getDate()) + ); +} diff --git a/src/DecryptionFailureTracker.js b/src/DecryptionFailureTracker.ts similarity index 79% rename from src/DecryptionFailureTracker.js rename to src/DecryptionFailureTracker.ts index b02a5e937b..df306a54f5 100644 --- a/src/DecryptionFailureTracker.js +++ b/src/DecryptionFailureTracker.ts @@ -1,5 +1,5 @@ /* -Copyright 2018 New Vector Ltd +Copyright 2018 - 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. @@ -14,34 +14,40 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { MatrixError } from "matrix-js-sdk/src/http-api"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + export class DecryptionFailure { - constructor(failedEventId, errorCode) { - this.failedEventId = failedEventId; - this.errorCode = errorCode; + public readonly ts: number; + + constructor(public readonly failedEventId: string, public readonly errorCode: string) { this.ts = Date.now(); } } +type TrackingFn = (count: number, trackedErrCode: string) => void; +type ErrCodeMapFn = (errcode: string) => string; + export class DecryptionFailureTracker { // Array of items of type DecryptionFailure. Every `CHECK_INTERVAL_MS`, this list // is checked for failures that happened > `GRACE_PERIOD_MS` ago. Those that did // are accumulated in `failureCounts`. - failures = []; + public failures: DecryptionFailure[] = []; // A histogram of the number of failures that will be tracked at the next tracking // interval, split by failure error code. - failureCounts = { + public failureCounts: Record = { // [errorCode]: 42 }; // Event IDs of failures that were tracked previously - trackedEventHashMap = { + public trackedEventHashMap: Record = { // [eventId]: true }; // Set to an interval ID when `start` is called - checkInterval = null; - trackInterval = null; + public checkInterval: number = null; + public trackInterval: number = null; // Spread the load on `Analytics` by tracking at a low frequency, `TRACK_INTERVAL_MS`. static TRACK_INTERVAL_MS = 60000; @@ -67,7 +73,7 @@ export class DecryptionFailureTracker { * @param {function?} errorCodeMapFn The function used to map error codes to the * trackedErrorCode. If not provided, the `.code` of errors will be used. */ - constructor(fn, errorCodeMapFn) { + constructor(private readonly fn: TrackingFn, private readonly errorCodeMapFn?: ErrCodeMapFn) { if (!fn || typeof fn !== 'function') { throw new Error('DecryptionFailureTracker requires tracking function'); } @@ -75,9 +81,6 @@ export class DecryptionFailureTracker { if (errorCodeMapFn && typeof errorCodeMapFn !== 'function') { throw new Error('DecryptionFailureTracker second constructor argument should be a function'); } - - this._trackDecryptionFailure = fn; - this._mapErrorCode = errorCodeMapFn; } // loadTrackedEventHashMap() { @@ -88,7 +91,7 @@ export class DecryptionFailureTracker { // localStorage.setItem('mx-decryption-failure-event-id-hashes', JSON.stringify(this.trackedEventHashMap)); // } - eventDecrypted(e, err) { + public eventDecrypted(e: MatrixEvent, err: MatrixError | Error): void { if (err) { this.addDecryptionFailure(new DecryptionFailure(e.getId(), err.code)); } else { @@ -97,18 +100,18 @@ export class DecryptionFailureTracker { } } - addDecryptionFailure(failure) { + public addDecryptionFailure(failure: DecryptionFailure): void { this.failures.push(failure); } - removeDecryptionFailuresForEvent(e) { + public removeDecryptionFailuresForEvent(e: MatrixEvent): void { this.failures = this.failures.filter((f) => f.failedEventId !== e.getId()); } /** * Start checking for and tracking failures. */ - start() { + public start(): void { this.checkInterval = setInterval( () => this.checkFailures(Date.now()), DecryptionFailureTracker.CHECK_INTERVAL_MS, @@ -123,7 +126,7 @@ export class DecryptionFailureTracker { /** * Clear state and stop checking for and tracking failures. */ - stop() { + public stop(): void { clearInterval(this.checkInterval); clearInterval(this.trackInterval); @@ -132,11 +135,11 @@ export class DecryptionFailureTracker { } /** - * Mark failures that occured before nowTs - GRACE_PERIOD_MS as failures that should be + * Mark failures that occurred before nowTs - GRACE_PERIOD_MS as failures that should be * tracked. Only mark one failure per event ID. * @param {number} nowTs the timestamp that represents the time now. */ - checkFailures(nowTs) { + public checkFailures(nowTs: number): void { const failuresGivenGrace = []; const failuresNotReady = []; while (this.failures.length > 0) { @@ -165,7 +168,7 @@ export class DecryptionFailureTracker { const trackedEventIds = [...dedupedFailuresMap.keys()]; this.trackedEventHashMap = trackedEventIds.reduce( - (result, eventId) => ({...result, [eventId]: true}), + (result, eventId) => ({ ...result, [eventId]: true }), this.trackedEventHashMap, ); @@ -175,10 +178,10 @@ export class DecryptionFailureTracker { const dedupedFailures = dedupedFailuresMap.values(); - this._aggregateFailures(dedupedFailures); + this.aggregateFailures(dedupedFailures); } - _aggregateFailures(failures) { + private aggregateFailures(failures: DecryptionFailure[]): void { for (const failure of failures) { const errorCode = failure.errorCode; this.failureCounts[errorCode] = (this.failureCounts[errorCode] || 0) + 1; @@ -189,12 +192,12 @@ export class DecryptionFailureTracker { * If there are failures that should be tracked, call the given trackDecryptionFailure * function with the number of failures that should be tracked. */ - trackFailures() { + public trackFailures(): void { for (const errorCode of Object.keys(this.failureCounts)) { if (this.failureCounts[errorCode] > 0) { - const trackedErrorCode = this._mapErrorCode ? this._mapErrorCode(errorCode) : errorCode; + const trackedErrorCode = this.errorCodeMapFn ? this.errorCodeMapFn(errorCode) : errorCode; - this._trackDecryptionFailure(this.failureCounts[errorCode], trackedErrorCode); + this.fn(this.failureCounts[errorCode], trackedErrorCode); this.failureCounts[errorCode] = 0; } } diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index df494e6bdd..f218e6c6a7 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from './MatrixClientPeg'; +import { MatrixClientPeg } from './MatrixClientPeg'; import dis from "./dispatcher/dispatcher"; import { hideToast as hideBulkUnverifiedSessionsToast, @@ -33,6 +33,9 @@ import { isSecretStorageBeingAccessed, accessSecretStorage } from "./SecurityMan import { isSecureBackupRequired } from './utils/WellKnownUtils'; import { isLoggedIn } from './components/structures/MatrixChat'; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { ActionPayload } from "./dispatcher/payloads"; + +import { logger } from "matrix-js-sdk/src/logger"; const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; @@ -58,28 +61,28 @@ export default class DeviceListener { } start() { - MatrixClientPeg.get().on('crypto.willUpdateDevices', this._onWillUpdateDevices); - MatrixClientPeg.get().on('crypto.devicesUpdated', this._onDevicesUpdated); - MatrixClientPeg.get().on('deviceVerificationChanged', this._onDeviceVerificationChanged); - MatrixClientPeg.get().on('userTrustStatusChanged', this._onUserTrustStatusChanged); - MatrixClientPeg.get().on('crossSigning.keysChanged', this._onCrossSingingKeysChanged); - MatrixClientPeg.get().on('accountData', this._onAccountData); - MatrixClientPeg.get().on('sync', this._onSync); - MatrixClientPeg.get().on('RoomState.events', this._onRoomStateEvents); - this.dispatcherRef = dis.register(this._onAction); - this._recheck(); + MatrixClientPeg.get().on('crypto.willUpdateDevices', this.onWillUpdateDevices); + MatrixClientPeg.get().on('crypto.devicesUpdated', this.onDevicesUpdated); + MatrixClientPeg.get().on('deviceVerificationChanged', this.onDeviceVerificationChanged); + MatrixClientPeg.get().on('userTrustStatusChanged', this.onUserTrustStatusChanged); + MatrixClientPeg.get().on('crossSigning.keysChanged', this.onCrossSingingKeysChanged); + MatrixClientPeg.get().on('accountData', this.onAccountData); + MatrixClientPeg.get().on('sync', this.onSync); + MatrixClientPeg.get().on('RoomState.events', this.onRoomStateEvents); + this.dispatcherRef = dis.register(this.onAction); + this.recheck(); } stop() { if (MatrixClientPeg.get()) { - MatrixClientPeg.get().removeListener('crypto.willUpdateDevices', this._onWillUpdateDevices); - MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this._onDevicesUpdated); - MatrixClientPeg.get().removeListener('deviceVerificationChanged', this._onDeviceVerificationChanged); - MatrixClientPeg.get().removeListener('userTrustStatusChanged', this._onUserTrustStatusChanged); - MatrixClientPeg.get().removeListener('crossSigning.keysChanged', this._onCrossSingingKeysChanged); - MatrixClientPeg.get().removeListener('accountData', this._onAccountData); - MatrixClientPeg.get().removeListener('sync', this._onSync); - MatrixClientPeg.get().removeListener('RoomState.events', this._onRoomStateEvents); + MatrixClientPeg.get().removeListener('crypto.willUpdateDevices', this.onWillUpdateDevices); + MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this.onDevicesUpdated); + MatrixClientPeg.get().removeListener('deviceVerificationChanged', this.onDeviceVerificationChanged); + MatrixClientPeg.get().removeListener('userTrustStatusChanged', this.onUserTrustStatusChanged); + MatrixClientPeg.get().removeListener('crossSigning.keysChanged', this.onCrossSingingKeysChanged); + MatrixClientPeg.get().removeListener('accountData', this.onAccountData); + MatrixClientPeg.get().removeListener('sync', this.onSync); + MatrixClientPeg.get().removeListener('RoomState.events', this.onRoomStateEvents); } if (this.dispatcherRef) { dis.unregister(this.dispatcherRef); @@ -99,19 +102,20 @@ export default class DeviceListener { * @param {String[]} deviceIds List of device IDs to dismiss notifications for */ async dismissUnverifiedSessions(deviceIds: Iterable) { + logger.log("Dismissing unverified sessions: " + Array.from(deviceIds).join(',')); for (const d of deviceIds) { this.dismissed.add(d); } - this._recheck(); + this.recheck(); } dismissEncryptionSetup() { this.dismissedThisDeviceToast = true; - this._recheck(); + this.recheck(); } - _ensureDeviceIdsAtStartPopulated() { + private ensureDeviceIdsAtStartPopulated() { if (this.ourDeviceIdsAtStart === null) { const cli = MatrixClientPeg.get(); this.ourDeviceIdsAtStart = new Set( @@ -120,39 +124,39 @@ export default class DeviceListener { } } - _onWillUpdateDevices = async (users: string[], initialFetch?: boolean) => { + private onWillUpdateDevices = async (users: string[], initialFetch?: boolean) => { // If we didn't know about *any* devices before (ie. it's fresh login), // then they are all pre-existing devices, so ignore this and set the // devicesAtStart list to the devices that we see after the fetch. if (initialFetch) return; const myUserId = MatrixClientPeg.get().getUserId(); - if (users.includes(myUserId)) this._ensureDeviceIdsAtStartPopulated(); + if (users.includes(myUserId)) this.ensureDeviceIdsAtStartPopulated(); // No need to do a recheck here: we just need to get a snapshot of our devices // before we download any new ones. }; - _onDevicesUpdated = (users: string[]) => { + private onDevicesUpdated = (users: string[]) => { if (!users.includes(MatrixClientPeg.get().getUserId())) return; - this._recheck(); + this.recheck(); }; - _onDeviceVerificationChanged = (userId: string) => { + private onDeviceVerificationChanged = (userId: string) => { if (userId !== MatrixClientPeg.get().getUserId()) return; - this._recheck(); + this.recheck(); }; - _onUserTrustStatusChanged = (userId: string) => { + private onUserTrustStatusChanged = (userId: string) => { if (userId !== MatrixClientPeg.get().getUserId()) return; - this._recheck(); + this.recheck(); }; - _onCrossSingingKeysChanged = () => { - this._recheck(); + private onCrossSingingKeysChanged = () => { + this.recheck(); }; - _onAccountData = (ev) => { + private onAccountData = (ev: MatrixEvent) => { // User may have: // * migrated SSSS to symmetric // * uploaded keys to secret storage @@ -160,34 +164,35 @@ export default class DeviceListener { // which result in account data changes affecting checks below. if ( ev.getType().startsWith('m.secret_storage.') || - ev.getType().startsWith('m.cross_signing.') + ev.getType().startsWith('m.cross_signing.') || + ev.getType() === 'm.megolm_backup.v1' ) { - this._recheck(); + this.recheck(); } }; - _onSync = (state, prevState) => { - if (state === 'PREPARED' && prevState === null) this._recheck(); + private onSync = (state, prevState) => { + if (state === 'PREPARED' && prevState === null) this.recheck(); }; - _onRoomStateEvents = (ev: MatrixEvent) => { + private onRoomStateEvents = (ev: MatrixEvent) => { if (ev.getType() !== "m.room.encryption") { return; } // If a room changes to encrypted, re-check as it may be our first // encrypted room. This also catches encrypted room creation as well. - this._recheck(); + this.recheck(); }; - _onAction = ({ action }) => { + private onAction = ({ action }: ActionPayload) => { if (action !== "on_logged_in") return; - this._recheck(); + this.recheck(); }; // The server doesn't tell us when key backup is set up, so we poll // & cache the result - async _getKeyBackupInfo() { + private async getKeyBackupInfo() { const now = (new Date()).getTime(); if (!this.keyBackupInfo || this.keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL) { this.keyBackupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); @@ -205,10 +210,10 @@ export default class DeviceListener { return cli && cli.getRooms().some(r => cli.isRoomEncrypted(r.roomId)); } - async _recheck() { + private async recheck() { const cli = MatrixClientPeg.get(); - if (!await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) return; + if (!(await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing"))) return; if (!cli.isCryptoEnabled()) return; // don't recheck until the initial sync is complete: lots of account data events will fire @@ -234,7 +239,7 @@ export default class DeviceListener { // Cross-signing on account but this device doesn't trust the master key (verify this session) showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION); } else { - const backupInfo = await this._getKeyBackupInfo(); + const backupInfo = await this.getKeyBackupInfo(); if (backupInfo) { // No cross-signing on account but key backup available (upgrade encryption) showSetupEncryptionToast(SetupKind.UPGRADE_ENCRYPTION); @@ -255,7 +260,7 @@ export default class DeviceListener { // This needs to be done after awaiting on downloadKeys() above, so // we make sure we get the devices after the fetch is done. - this._ensureDeviceIdsAtStartPopulated(); + this.ensureDeviceIdsAtStartPopulated(); // Unverified devices that were there last time the app ran // (technically could just be a boolean: we don't actually @@ -283,6 +288,9 @@ export default class DeviceListener { } } + logger.log("Old unverified sessions: " + Array.from(oldUnverifiedDeviceIds).join(',')); + logger.log("New unverified sessions: " + Array.from(newUnverifiedDeviceIds).join(',')); + // Display or hide the batch toast for old unverified sessions if (oldUnverifiedDeviceIds.size > 0) { showBulkUnverifiedSessionsToast(oldUnverifiedDeviceIds); diff --git a/src/GroupAddressPicker.js b/src/GroupAddressPicker.js index 9497d9de4c..ea1813876c 100644 --- a/src/GroupAddressPicker.js +++ b/src/GroupAddressPicker.js @@ -19,7 +19,7 @@ import Modal from './Modal'; import * as sdk from './'; import MultiInviter from './utils/MultiInviter'; import { _t } from './languageHandler'; -import {MatrixClientPeg} from './MatrixClientPeg'; +import { MatrixClientPeg } from './MatrixClientPeg'; import GroupStore from './stores/GroupStore'; import StyledCheckbox from './components/views/elements/StyledCheckbox'; @@ -103,7 +103,7 @@ function _onGroupInviteFinished(groupId, addrs) { if (errorList.length > 0) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to invite the following users to the group', '', ErrorDialog, { - title: _t("Failed to invite the following users to %(groupId)s:", {groupId: groupId}), + title: _t("Failed to invite the following users to %(groupId)s:", { groupId: groupId }), description: errorList.join(", "), }); } @@ -111,7 +111,7 @@ function _onGroupInviteFinished(groupId, addrs) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to invite users to group', '', ErrorDialog, { title: _t("Failed to invite users to community"), - description: _t("Failed to invite users to %(groupId)s", {groupId: groupId}), + description: _t("Failed to invite users to %(groupId)s", { groupId: groupId }), }); }); } @@ -137,7 +137,7 @@ function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) { // Add this group as related if (!groups.includes(groupId)) { groups.push(groupId); - return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.related_groups', {groups}, ''); + return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.related_groups', { groups }, ''); } }); })).then(() => { @@ -152,7 +152,7 @@ function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) { { title: _t( "Failed to add the following rooms to %(groupId)s:", - {groupId}, + { groupId }, ), description: errorList.join(", "), }, diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index ef5ac383e3..2eee5214af 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -17,25 +17,25 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import sanitizeHtml from 'sanitize-html'; -import { IExtendedSanitizeOptions } from './@types/sanitize-html'; +import cheerio from 'cheerio'; import * as linkify from 'linkifyjs'; -import linkifyMatrix from './linkify-matrix'; import _linkifyElement from 'linkifyjs/element'; import _linkifyString from 'linkifyjs/string'; import classNames from 'classnames'; import EMOJIBASE_REGEX from 'emojibase-regex'; -import url from 'url'; import katex from 'katex'; import { AllHtmlEntities } from 'html-entities'; -import SettingsStore from './settings/SettingsStore'; -import cheerio from 'cheerio'; +import { IContent } from 'matrix-js-sdk/src/models/event'; -import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks"; -import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji"; +import { IExtendedSanitizeOptions } from './@types/sanitize-html'; +import linkifyMatrix from './linkify-matrix'; +import SettingsStore from './settings/SettingsStore'; +import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks"; +import { getEmojiFromUnicode } from "./emoji"; import ReplyThread from "./components/views/elements/ReplyThread"; -import {mediaFromMxc} from "./customisations/Media"; +import { mediaFromMxc } from "./customisations/Media"; linkifyMatrix(linkify); @@ -57,7 +57,35 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i'); const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; -export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; +export const PERMITTED_URL_SCHEMES = [ + "bitcoin", + "ftp", + "geo", + "http", + "https", + "im", + "irc", + "ircs", + "magnet", + "mailto", + "matrix", + "mms", + "news", + "nntp", + "openpgp4fpr", + "sip", + "sftp", + "sms", + "smsto", + "ssh", + "tel", + "urn", + "webcal", + "wtai", + "xmpp", +]; + +const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/; /* * Return true if the given string contains emoji @@ -66,7 +94,7 @@ export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet' * need emojification. * unicodeToImage uses this function. */ -function mightContainEmoji(str: string) { +function mightContainEmoji(str: string): boolean { return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str); } @@ -76,21 +104,9 @@ function mightContainEmoji(str: string) { * @param {String} char The emoji character * @return {String} The shortcode (such as :thumbup:) */ -export function unicodeToShortcode(char: string) { - const data = getEmojiFromUnicode(char); - return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : ''); -} - -/** - * Returns the unicode character for an emoji shortcode - * - * @param {String} shortcode The shortcode (such as :thumbup:) - * @return {String} The emoji character; null if none exists - */ -export function shortcodeToUnicode(shortcode: string) { - shortcode = shortcode.slice(1, shortcode.length - 1); - const data = SHORTCODE_TO_EMOJI.get(shortcode); - return data ? data.unicode : null; +export function unicodeToShortcode(char: string): string { + const shortcodes = getEmojiFromUnicode(char)?.shortcodes; + return shortcodes?.length ? `:${shortcodes[0]}:` : ''; } export function processHtmlForSending(html: string): string { @@ -124,20 +140,20 @@ export function processHtmlForSending(html: string): string { * Given an untrusted HTML string, return a React node with an sanitized version * of that HTML. */ -export function sanitizedHtmlNode(insaneHtml: string) { +export function sanitizedHtmlNode(insaneHtml: string): ReactNode { const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); return
; } -export function getHtmlText(insaneHtml: string) { +export function getHtmlText(insaneHtml: string): string { return sanitizeHtml(insaneHtml, { allowedTags: [], allowedAttributes: {}, selfClosing: [], allowedSchemes: [], disallowedTagsMode: 'discard', - }) + }); } /** @@ -148,12 +164,10 @@ export function getHtmlText(insaneHtml: string) { * other places we need to sanitise URLs. * @return true if permitted, otherwise false */ -export function isUrlPermitted(inputUrl: string) { +export function isUrlPermitted(inputUrl: string): boolean { try { - const parsed = url.parse(inputUrl); - if (!parsed.protocol) return false; // URL parser protocol includes the trailing colon - return PERMITTED_URL_SCHEMES.includes(parsed.protocol.slice(0, -1)); + return PERMITTED_URL_SCHEMES.includes(new URL(inputUrl).protocol.slice(0, -1)); } catch (e) { return false; } @@ -175,18 +189,31 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to return { tagName, attribs }; }, 'img': function(tagName: string, attribs: sanitizeHtml.Attributes) { + let src = attribs.src; // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag // because transformTags is used _before_ we filter by allowedSchemesByTag and // we don't want to allow images with `https?` `src`s. // We also drop inline images (as if they were not present at all) when the "show // images" preference is disabled. Future work might expose some UI to reveal them // like standalone image events have. - if (!attribs.src || !attribs.src.startsWith('mxc://') || !SettingsStore.getValue("showImages")) { - return { tagName, attribs: {}}; + if (!src || !SettingsStore.getValue("showImages")) { + return { tagName, attribs: {} }; } + + if (!src.startsWith("mxc://")) { + const match = MEDIA_API_MXC_REGEX.exec(src); + if (match) { + src = `mxc://${match[1]}/${match[2]}`; + } + } + + if (!src.startsWith("mxc://")) { + return { tagName, attribs: {} }; + } + const width = Number(attribs.width) || 800; const height = Number(attribs.height) || 600; - attribs.src = mediaFromMxc(attribs.src).getThumbnailOfSourceHttp(width, height); + attribs.src = mediaFromMxc(src).getThumbnailOfSourceHttp(width, height); return { tagName, attribs }; }, 'code': function(tagName: string, attribs: sanitizeHtml.Attributes) { @@ -351,20 +378,21 @@ class HtmlHighlighter extends BaseHighlighter { } } -interface IContent { - format?: string; - // eslint-disable-next-line camelcase - formatted_body?: string; - body: string; -} - interface IOpts { highlightLink?: string; disableBigEmoji?: boolean; stripReplyFallback?: boolean; returnString?: boolean; forComposerQuote?: boolean; - ref?: React.Ref; + ref?: React.Ref; +} + +export interface IOptsReturnNode extends IOpts { + returnString: false | undefined; +} + +export interface IOptsReturnString extends IOpts { + returnString: true; } /* turn a matrix event body into html @@ -380,6 +408,8 @@ interface IOpts { * opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer * opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString) */ +export function bodyToHtml(content: IContent, highlights: string[], opts: IOptsReturnString): string; +export function bodyToHtml(content: IContent, highlights: string[], opts: IOptsReturnNode): ReactNode; export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts = {}) { const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body; let bodyHasEmoji = false; @@ -399,9 +429,14 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts try { if (highlights && highlights.length > 0) { const highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink); - const safeHighlights = highlights.map(function(highlight) { - return sanitizeHtml(highlight, sanitizeParams); - }); + const safeHighlights = highlights + // sanitizeHtml can hang if an unclosed HTML tag is thrown at it + // A search for ` !highlight.includes("<")) + .map((highlight: string): string => sanitizeHtml(highlight, sanitizeParams)); // XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure. sanitizeParams.textFilter = function(safeText) { return highlighter.applyHighlights(safeText, safeHighlights).join(''); @@ -501,7 +536,7 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts * @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options * @returns {string} Linkified string */ -export function linkifyString(str: string, options = linkifyMatrix.options) { +export function linkifyString(str: string, options = linkifyMatrix.options): string { return _linkifyString(str, options); } @@ -512,7 +547,7 @@ export function linkifyString(str: string, options = linkifyMatrix.options) { * @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options * @returns {object} */ -export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options) { +export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options): HTMLElement { return _linkifyElement(element, options); } @@ -523,7 +558,7 @@ export function linkifyElement(element: HTMLElement, options = linkifyMatrix.opt * @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options * @returns {string} */ -export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options) { +export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options): string { return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams); } @@ -534,7 +569,7 @@ export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatri * @param {Node} node * @returns {bool} */ -export function checkBlockNode(node: Node) { +export function checkBlockNode(node: Node): boolean { switch (node.nodeName) { case "H1": case "H2": diff --git a/src/IdentityAuthClient.js b/src/IdentityAuthClient.tsx similarity index 68% rename from src/IdentityAuthClient.js rename to src/IdentityAuthClient.tsx index 9239c1bc75..ae55c20438 100644 --- a/src/IdentityAuthClient.js +++ b/src/IdentityAuthClient.tsx @@ -14,12 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ +import React from "react"; import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types'; -import { createClient } from 'matrix-js-sdk/src/matrix'; +import { createClient, MatrixClient } from 'matrix-js-sdk/src/matrix'; -import {MatrixClientPeg} from './MatrixClientPeg'; +import { MatrixClientPeg } from './MatrixClientPeg'; import Modal from './Modal'; -import * as sdk from './index'; import { _t } from './languageHandler'; import { Service, startTermsFlow, TermsNotSignedError } from './Terms'; import { @@ -27,21 +27,25 @@ import { doesIdentityServerHaveTerms, useDefaultIdentityServer, } from './utils/IdentityServerUtils'; -import { abbreviateUrl } from './utils/UrlUtils'; + +import { logger } from "matrix-js-sdk/src/logger"; +import QuestionDialog from "./components/views/dialogs/QuestionDialog"; +import { abbreviateUrl } from "./utils/UrlUtils"; export class AbortedIdentityActionError extends Error {} export default class IdentityAuthClient { + private accessToken: string; + private tempClient: MatrixClient; + private authEnabled = true; + /** * Creates a new identity auth client * @param {string} identityUrl The URL to contact the identity server with. * When provided, this class will operate solely within memory, refusing to * persist any information such as tokens. Default null (not provided). */ - constructor(identityUrl = null) { - this.accessToken = null; - this.authEnabled = true; - + constructor(identityUrl?: string) { if (identityUrl) { // XXX: We shouldn't have to create a whole new MatrixClient just to // do identity server auth. The functions don't take an identity URL @@ -52,32 +56,29 @@ export default class IdentityAuthClient { baseUrl: "", // invalid by design idBaseUrl: identityUrl, }); - } else { - // Indicates that we're using the real client, not some workaround. - this.tempClient = null; } } - get _matrixClient() { + private get matrixClient(): MatrixClient { return this.tempClient ? this.tempClient : MatrixClientPeg.get(); } - _writeToken() { + private writeToken(): void { if (this.tempClient) return; // temporary client: ignore window.localStorage.setItem("mx_is_access_token", this.accessToken); } - _readToken() { + private readToken(): string { if (this.tempClient) return null; // temporary client: ignore return window.localStorage.getItem("mx_is_access_token"); } - hasCredentials() { - return this.accessToken != null; // undef or null + public hasCredentials(): boolean { + return Boolean(this.accessToken); } // Returns a promise that resolves to the access_token string from the IS - async getAccessToken({ check = true } = {}) { + public async getAccessToken({ check = true } = {}): Promise { if (!this.authEnabled) { // The current IS doesn't support authentication return null; @@ -85,21 +86,21 @@ export default class IdentityAuthClient { let token = this.accessToken; if (!token) { - token = this._readToken(); + token = this.readToken(); } if (!token) { token = await this.registerForToken(check); if (token) { this.accessToken = token; - this._writeToken(); + this.writeToken(); } return token; } if (check) { try { - await this._checkToken(token); + await this.checkToken(token); } catch (e) { if ( e instanceof TermsNotSignedError || @@ -112,7 +113,7 @@ export default class IdentityAuthClient { token = await this.registerForToken(); if (token) { this.accessToken = token; - this._writeToken(); + this.writeToken(); } } } @@ -120,14 +121,14 @@ export default class IdentityAuthClient { return token; } - async _checkToken(token) { - const identityServerUrl = this._matrixClient.getIdentityServerUrl(); + private async checkToken(token: string): Promise { + const identityServerUrl = this.matrixClient.getIdentityServerUrl(); try { - await this._matrixClient.getIdentityAccount(token); + await this.matrixClient.getIdentityAccount(token); } catch (e) { if (e.errcode === "M_TERMS_NOT_SIGNED") { - console.log("Identity Server requires new terms to be agreed to"); + logger.log("Identity server requires new terms to be agreed to"); await startTermsFlow([new Service( SERVICE_TYPES.IS, identityServerUrl, @@ -141,28 +142,28 @@ export default class IdentityAuthClient { if ( !this.tempClient && !doesAccountDataHaveIdentityServer() && - !await doesIdentityServerHaveTerms(identityServerUrl) + !(await doesIdentityServerHaveTerms(identityServerUrl)) ) { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - const { finished } = Modal.createTrackedDialog('Default identity server terms warning', '', + const { finished } = Modal.createTrackedDialog( + 'Default identity server terms warning', '', QuestionDialog, { - title: _t("Identity server has no terms of service"), - description: ( -
-

{_t( - "This action requires accessing the default identity server " + + title: _t("Identity server has no terms of service"), + description: ( +

+

{ _t( + "This action requires accessing the default identity server " + " to validate an email address or phone number, " + "but the server does not have any terms of service.", {}, - { - server: () => {abbreviateUrl(identityServerUrl)}, - }, - )}

-

{_t( - "Only continue if you trust the owner of the server.", - )}

-
- ), - button: _t("Trust"), + { + server: () => { abbreviateUrl(identityServerUrl) }, + }, + ) }

+

{ _t( + "Only continue if you trust the owner of the server.", + ) }

+
+ ), + button: _t("Trust"), }); const [confirmed] = await finished; if (confirmed) { @@ -182,13 +183,13 @@ export default class IdentityAuthClient { // See also https://github.com/vector-im/element-web/issues/10455. } - async registerForToken(check=true) { + public async registerForToken(check = true): Promise { const hsOpenIdToken = await MatrixClientPeg.get().getOpenIdToken(); // XXX: The spec is `token`, but we used `access_token` for a Sydent release. const { access_token: accessToken, token } = - await this._matrixClient.registerWithIdentityServer(hsOpenIdToken); + await this.matrixClient.registerWithIdentityServer(hsOpenIdToken); const identityAccessToken = token ? token : accessToken; - if (check) await this._checkToken(identityAccessToken); + if (check) await this.checkToken(identityAccessToken); return identityAccessToken; } } diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts index 63c4ac0f86..6169f431f4 100644 --- a/src/KeyBindingsDefaults.ts +++ b/src/KeyBindingsDefaults.ts @@ -156,36 +156,34 @@ const messageComposerBindings = (): KeyBinding[] => { } } return bindings; -} +}; const autocompleteBindings = (): KeyBinding[] => { return [ { - action: AutocompleteAction.CompleteOrNextSelection, + action: AutocompleteAction.ForceComplete, keyCombo: { key: Key.TAB, }, }, { - action: AutocompleteAction.CompleteOrNextSelection, + action: AutocompleteAction.ForceComplete, keyCombo: { key: Key.TAB, ctrlKey: true, }, }, { - action: AutocompleteAction.CompleteOrPrevSelection, + action: AutocompleteAction.Complete, keyCombo: { - key: Key.TAB, - shiftKey: true, + key: Key.ENTER, }, }, { - action: AutocompleteAction.CompleteOrPrevSelection, + action: AutocompleteAction.Complete, keyCombo: { - key: Key.TAB, + key: Key.ENTER, ctrlKey: true, - shiftKey: true, }, }, { @@ -207,7 +205,7 @@ const autocompleteBindings = (): KeyBinding[] => { }, }, ]; -} +}; const roomListBindings = (): KeyBinding[] => { return [ @@ -248,7 +246,7 @@ const roomListBindings = (): KeyBinding[] => { }, }, ]; -} +}; const roomBindings = (): KeyBinding[] => { const bindings: KeyBinding[] = [ @@ -312,7 +310,7 @@ const roomBindings = (): KeyBinding[] => { } return bindings; -} +}; const navigationBindings = (): KeyBinding[] => { return [ @@ -396,7 +394,7 @@ const navigationBindings = (): KeyBinding[] => { }, }, ]; -} +}; export const defaultBindingsProvider: IKeyBindingsProvider = { getMessageComposerBindings: messageComposerBindings, @@ -404,4 +402,4 @@ export const defaultBindingsProvider: IKeyBindingsProvider = { getRoomListBindings: roomListBindings, getRoomBindings: roomBindings, getNavigationBindings: navigationBindings, -} +}; diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index aac14bde20..3a893e2ec8 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -52,13 +52,11 @@ export enum MessageComposerAction { /** Actions for text editing autocompletion */ export enum AutocompleteAction { - /** - * Select previous selection or, if the autocompletion window is not shown, open the window and select the first - * selection. - */ - CompleteOrPrevSelection = 'ApplySelection', - /** Select next selection or, if the autocompletion window is not shown, open it and select the first selection */ - CompleteOrNextSelection = 'CompleteOrNextSelection', + /** Accepts chosen autocomplete selection */ + Complete = 'Complete', + /** Accepts chosen autocomplete selection or, + * if the autocompletion window is not shown, open the window and select the first selection */ + ForceComplete = 'ForceComplete', /** Move to the previous autocomplete selection */ PrevSelection = 'PrevSelection', /** Move to the next autocomplete selection */ @@ -140,12 +138,12 @@ export type KeyCombo = { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean; -} +}; export type KeyBinding = { action: T; keyCombo: KeyCombo; -} +}; /** * Helper method to check if a KeyboardEvent matches a KeyCombo diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index b0a1292ba1..3685f7b938 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -20,9 +20,10 @@ limitations under the License. import { createClient } from 'matrix-js-sdk/src/matrix'; import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { MatrixClient } from "matrix-js-sdk/src/client"; -import {decryptAES, encryptAES} from "matrix-js-sdk/src/crypto/aes"; +import { decryptAES, encryptAES, IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes"; +import { QueryDict } from 'matrix-js-sdk/src/utils'; -import {IMatrixClientCreds, MatrixClientPeg} from './MatrixClientPeg'; +import { IMatrixClientCreds, MatrixClientPeg } from './MatrixClientPeg'; import SecurityCustomisations from "./customisations/Security"; import EventIndexPeg from './indexing/EventIndexPeg'; import createMatrixClient from './utils/createMatrixClient'; @@ -33,7 +34,6 @@ import Presence from './Presence'; import dis from './dispatcher/dispatcher'; import DMRoomMap from './utils/DMRoomMap'; import Modal from './Modal'; -import * as sdk from './index'; import ActiveWidgetStore from './stores/ActiveWidgetStore'; import PlatformPeg from "./PlatformPeg"; import { sendLoginRequest } from "./Login"; @@ -41,17 +41,24 @@ import * as StorageManager from './utils/StorageManager'; import SettingsStore from "./settings/SettingsStore"; import TypingStore from "./stores/TypingStore"; import ToastStore from "./stores/ToastStore"; -import {IntegrationManagers} from "./integrations/IntegrationManagers"; -import {Mjolnir} from "./mjolnir/Mjolnir"; +import { IntegrationManagers } from "./integrations/IntegrationManagers"; +import { Mjolnir } from "./mjolnir/Mjolnir"; import DeviceListener from "./DeviceListener"; -import {Jitsi} from "./widgets/Jitsi"; -import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY} from "./BasePlatform"; +import { Jitsi } from "./widgets/Jitsi"; +import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY } from "./BasePlatform"; import ThreepidInviteStore from "./stores/ThreepidInviteStore"; import CountlyAnalytics from "./CountlyAnalytics"; +import { PosthogAnalytics } from "./PosthogAnalytics"; import CallHandler from './CallHandler'; import LifecycleCustomisations from "./customisations/Lifecycle"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; -import {_t} from "./languageHandler"; +import { _t } from "./languageHandler"; +import LazyLoadingResyncDialog from "./components/views/dialogs/LazyLoadingResyncDialog"; +import LazyLoadingDisabledDialog from "./components/views/dialogs/LazyLoadingDisabledDialog"; +import SessionRestoreErrorDialog from "./components/views/dialogs/SessionRestoreErrorDialog"; +import StorageEvictedDialog from "./components/views/dialogs/StorageEvictedDialog"; + +import { logger } from "matrix-js-sdk/src/logger"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -62,7 +69,7 @@ interface ILoadSessionOpts { guestIsUrl?: string; ignoreGuest?: boolean; defaultDeviceDisplayName?: string; - fragmentQueryParams?: Record; + fragmentQueryParams?: QueryDict; } /** @@ -113,10 +120,10 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise fragmentQueryParams.guest_user_id && fragmentQueryParams.guest_access_token ) { - console.log("Using guest access credentials"); + logger.log("Using guest access credentials"); return doSetLoggedIn({ - userId: fragmentQueryParams.guest_user_id, - accessToken: fragmentQueryParams.guest_access_token, + userId: fragmentQueryParams.guest_user_id as string, + accessToken: fragmentQueryParams.guest_access_token as string, homeserverUrl: guestHsUrl, identityServerUrl: guestIsUrl, guest: true, @@ -154,7 +161,7 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise * return [null, null]. */ export async function getStoredSessionOwner(): Promise<[string, boolean]> { - const {hsUrl, userId, hasAccessToken, isGuest} = await getStoredSessionVars(); + const { hsUrl, userId, hasAccessToken, isGuest } = await getStoredSessionVars(); return hsUrl && userId && hasAccessToken ? [userId, isGuest] : [null, null]; } @@ -170,7 +177,7 @@ export async function getStoredSessionOwner(): Promise<[string, boolean]> { * login, else false */ export function attemptTokenLogin( - queryParams: Record, + queryParams: QueryDict, defaultDeviceDisplayName?: string, fragmentAfterLogin?: string, ): Promise { @@ -195,11 +202,11 @@ export function attemptTokenLogin( homeserver, identityServer, "m.login.token", { - token: queryParams.loginToken, + token: queryParams.loginToken as string, initial_device_display_name: defaultDeviceDisplayName, }, ).then(function(creds) { - console.log("Logged in with token"); + logger.log("Logged in with token"); return clearStorage().then(async () => { await persistCredentials(creds); // remember that we just logged in @@ -238,8 +245,6 @@ export function handleInvalidStoreError(e: InvalidStoreError): Promise { return Promise.resolve().then(() => { const lazyLoadEnabled = e.value; if (lazyLoadEnabled) { - const LazyLoadingResyncDialog = - sdk.getComponent("views.dialogs.LazyLoadingResyncDialog"); return new Promise((resolve) => { Modal.createDialog(LazyLoadingResyncDialog, { onFinished: resolve, @@ -250,8 +255,6 @@ export function handleInvalidStoreError(e: InvalidStoreError): Promise { // between LL/non-LL version on same host. // as disabling LL when previously enabled // is a strong indicator of this (/develop & /app) - const LazyLoadingDisabledDialog = - sdk.getComponent("views.dialogs.LazyLoadingDisabledDialog"); return new Promise((resolve) => { Modal.createDialog(LazyLoadingDisabledDialog, { onFinished: resolve, @@ -272,7 +275,7 @@ function registerAsGuest( isUrl: string, defaultDeviceDisplayName: string, ): Promise { - console.log(`Doing guest login on ${hsUrl}`); + logger.log(`Doing guest login on ${hsUrl}`); // create a temporary MatrixClient to do the login const client = createClient({ @@ -284,7 +287,7 @@ function registerAsGuest( initial_device_display_name: defaultDeviceDisplayName, }, }).then((creds) => { - console.log(`Registered as guest: ${creds.user_id}`); + logger.log(`Registered as guest: ${creds.user_id}`); return doSetLoggedIn({ userId: creds.user_id, deviceId: creds.device_id, @@ -303,7 +306,7 @@ export interface IStoredSession { hsUrl: string; isUrl: string; hasAccessToken: boolean; - accessToken: string | object; + accessToken: string | IEncryptedPayload; userId: string; deviceId: string; isGuest: boolean; @@ -346,11 +349,11 @@ export async function getStoredSessionVars(): Promise { isGuest = localStorage.getItem("matrix-is-guest") === "true"; } - return {hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest}; + return { hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest }; } // The pickle key is a string of unspecified length and format. For AES, we -// need a 256-bit Uint8Array. So we HKDF the pickle key to generate the AES +// need a 256-bit Uint8Array. So we HKDF the pickle key to generate the AES // key. The AES key should be zeroed after it is used. async function pickleKeyToAesKey(pickleKey: string): Promise { const pickleKeyBuffer = new Uint8Array(pickleKey.length); @@ -402,7 +405,7 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): return false; } - const {hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest} = await getStoredSessionVars(); + const { hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest } = await getStoredSessionVars(); if (hasAccessToken && !accessToken) { abortLogin(); @@ -410,27 +413,27 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): if (accessToken && userId && hsUrl) { if (ignoreGuest && isGuest) { - console.log("Ignoring stored guest account: " + userId); + logger.log("Ignoring stored guest account: " + userId); return false; } let decryptedAccessToken = accessToken; const pickleKey = await PlatformPeg.get().getPickleKey(userId, deviceId); if (pickleKey) { - console.log("Got pickle key"); + logger.log("Got pickle key"); if (typeof accessToken !== "string") { const encrKey = await pickleKeyToAesKey(pickleKey); decryptedAccessToken = await decryptAES(accessToken, encrKey, "access_token"); encrKey.fill(0); } } else { - console.log("No pickle key available"); + logger.log("No pickle key available"); } const freshLogin = sessionStorage.getItem("mx_fresh_login") === "true"; sessionStorage.removeItem("mx_fresh_login"); - console.log(`Restoring session for ${userId}`); + logger.log(`Restoring session for ${userId}`); await doSetLoggedIn({ userId: userId, deviceId: deviceId, @@ -443,7 +446,7 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): }, false); return true; } else { - console.log("No previous session found."); + logger.log("No previous session found."); return false; } } @@ -451,9 +454,6 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): async function handleLoadSessionFailure(e: Error): Promise { console.error("Unable to load session", e); - const SessionRestoreErrorDialog = - sdk.getComponent('views.dialogs.SessionRestoreErrorDialog'); - const modal = Modal.createTrackedDialog('Session Restore Error', '', SessionRestoreErrorDialog, { error: e.message, }); @@ -490,12 +490,12 @@ export async function setLoggedIn(credentials: IMatrixClientCreds): Promise { - const StorageEvictedDialog = sdk.getComponent('views.dialogs.StorageEvictedDialog'); return new Promise(resolve => { Modal.createTrackedDialog('Storage evicted', '', StorageEvictedDialog, { onFinished: resolve, @@ -689,7 +691,7 @@ async function persistCredentials(credentials: IMatrixClientCreds): Promise { - console.log(`Lifecycle: Starting MatrixClient`); + logger.log(`Lifecycle: Starting MatrixClient`); // dispatch this before starting the matrix client: it's used // to add listeners for the 'sync' event so otherwise we'd have // a race condition (and we need to dispatch synchronously for this // to work). - dis.dispatch({action: 'will_start_client'}, true); + dis.dispatch({ action: 'will_start_client' }, true); // reset things first just in case TypingStore.sharedInstance().reset(); @@ -782,7 +786,7 @@ async function startMatrixClient(startSyncing = true): Promise { UserActivity.sharedInstance().start(); DMRoomMap.makeShared().start(); IntegrationManagers.sharedInstance().startWatching(); - ActiveWidgetStore.start(); + ActiveWidgetStore.instance.start(); CallHandler.sharedInstance().start(); // Start Mjolnir even though we haven't checked the feature flag yet. Starting @@ -814,7 +818,7 @@ async function startMatrixClient(startSyncing = true): Promise { // dispatch that we finished starting up to wire up any other bits // of the matrix client that cannot be set prior to starting up. - dis.dispatch({action: 'client_started'}); + dis.dispatch({ action: 'client_started' }); if (isSoftLogout()) { softLogout(); @@ -830,9 +834,9 @@ export async function onLoggedOut(): Promise { // Ensure that we dispatch a view change **before** stopping the client so // so that React components unmount first. This avoids React soft crashes // that can occur when components try to use a null client. - dis.dispatch({action: 'on_logged_out'}, true); + dis.dispatch({ action: 'on_logged_out' }, true); stopMatrixClient(); - await clearStorage({deleteEverything: true}); + await clearStorage({ deleteEverything: true }); LifecycleCustomisations.onLoggedOutAndStorageCleared?.(); } @@ -888,7 +892,7 @@ export function stopMatrixClient(unsetClient = true): void { UserActivity.sharedInstance().stop(); TypingStore.sharedInstance().reset(); Presence.stop(); - ActiveWidgetStore.stop(); + ActiveWidgetStore.instance.stop(); IntegrationManagers.sharedInstance().stopWatching(); Mjolnir.sharedInstance().stop(); DeviceListener.sharedInstance().stop(); diff --git a/src/Login.ts b/src/Login.ts index 7caab22d88..bb1ab2ef36 100644 --- a/src/Login.ts +++ b/src/Login.ts @@ -16,11 +16,13 @@ limitations under the License. */ // @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising -import {createClient} from "matrix-js-sdk/src/matrix"; +import { createClient } from "matrix-js-sdk/src/matrix"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { IMatrixClientCreds } from "./MatrixClientPeg"; import SecurityCustomisations from "./customisations/Security"; +import { logger } from "matrix-js-sdk/src/logger"; + interface ILoginOptions { defaultDeviceDisplayName?: string; } @@ -166,7 +168,7 @@ export default class Login { return sendLoginRequest( this.fallbackHsUrl, this.isUrl, 'm.login.password', loginParams, ).catch((fallbackError) => { - console.log("fallback HS login failed", fallbackError); + logger.log("fallback HS login failed", fallbackError); // throw the original error throw originalError; }); @@ -184,13 +186,12 @@ export default class Login { } throw originalLoginError; }).catch((error) => { - console.log("Login failed", error); + logger.log("Login failed", error); throw error; }); } } - /** * Send a login request to the given server, and format the response * as a MatrixClientCreds @@ -219,12 +220,12 @@ export async function sendLoginRequest( if (wellknown) { if (wellknown["m.homeserver"] && wellknown["m.homeserver"]["base_url"]) { hsUrl = wellknown["m.homeserver"]["base_url"]; - console.log(`Overrode homeserver setting with ${hsUrl} from login response`); + logger.log(`Overrode homeserver setting with ${hsUrl} from login response`); } if (wellknown["m.identity_server"] && wellknown["m.identity_server"]["base_url"]) { // TODO: should we prompt here? isUrl = wellknown["m.identity_server"]["base_url"]; - console.log(`Overrode IS setting with ${isUrl} from login response`); + logger.log(`Overrode IS setting with ${isUrl} from login response`); } } diff --git a/src/Markdown.js b/src/Markdown.ts similarity index 74% rename from src/Markdown.js rename to src/Markdown.ts index f670bded12..96169d4011 100644 --- a/src/Markdown.js +++ b/src/Markdown.ts @@ -1,5 +1,6 @@ /* Copyright 2016 OpenMarket Ltd +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. @@ -15,16 +16,24 @@ limitations under the License. */ import * as commonmark from 'commonmark'; -import {escape} from "lodash"; +import { escape } from "lodash"; const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u']; // These types of node are definitely text const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document']; -function is_allowed_html_tag(node) { +// As far as @types/commonmark is concerned, these are not public, so add them +interface CommonmarkHtmlRendererInternal extends commonmark.HtmlRenderer { + paragraph: (node: commonmark.Node, entering: boolean) => void; + link: (node: commonmark.Node, entering: boolean) => void; + html_inline: (node: commonmark.Node) => void; // eslint-disable-line camelcase + html_block: (node: commonmark.Node) => void; // eslint-disable-line camelcase +} + +function isAllowedHtmlTag(node: commonmark.Node): boolean { if (node.literal != null && - node.literal.match('^<((div|span) data-mx-maths="[^"]*"|\/(div|span))>$') != null) { + node.literal.match('^<((div|span) data-mx-maths="[^"]*"|/(div|span))>$') != null) { return true; } @@ -39,21 +48,12 @@ function is_allowed_html_tag(node) { return false; } -function html_if_tag_allowed(node) { - if (is_allowed_html_tag(node)) { - this.lit(node.literal); - return; - } else { - this.lit(escape(node.literal)); - } -} - /* * Returns true if the parse output containing the node * comprises multiple block level elements (ie. lines), * or false if it is only a single line. */ -function is_multi_line(node) { +function isMultiLine(node: commonmark.Node): boolean { let par = node; while (par.parent) { par = par.parent; @@ -67,6 +67,9 @@ function is_multi_line(node) { * it's plain text. */ export default class Markdown { + private input: string; + private parsed: commonmark.Node; + constructor(input) { this.input = input; @@ -74,7 +77,7 @@ export default class Markdown { this.parsed = parser.parse(this.input); } - isPlainText() { + isPlainText(): boolean { const walker = this.parsed.walker(); let ev; @@ -87,7 +90,7 @@ export default class Markdown { // if it's an allowed html tag, we need to render it and therefore // we will need to use HTML. If it's not allowed, it's not HTML since // we'll just be treating it as text. - if (is_allowed_html_tag(node)) { + if (isAllowedHtmlTag(node)) { return false; } } else { @@ -97,7 +100,7 @@ export default class Markdown { return true; } - toHTML({ externalLinks = false } = {}) { + toHTML({ externalLinks = false } = {}): string { const renderer = new commonmark.HtmlRenderer({ safe: false, @@ -107,7 +110,7 @@ export default class Markdown { // block quote ends up all on one line // (https://github.com/vector-im/element-web/issues/3154) softbreak: '
', - }); + }) as CommonmarkHtmlRendererInternal; // Trying to strip out the wrapping

causes a lot more complication // than it's worth, i think. For instance, this code will go and strip @@ -118,16 +121,16 @@ export default class Markdown { // // Let's try sending with

s anyway for now, though. - const real_paragraph = renderer.paragraph; + const realParagraph = renderer.paragraph; - renderer.paragraph = function(node, entering) { + renderer.paragraph = function(node: commonmark.Node, entering: boolean) { // If there is only one top level node, just return the // bare text: it's a single line of text and so should be // 'inline', rather than unnecessarily wrapped in its own // p tag. If, however, we have multiple nodes, each gets // its own p tag to keep them as separate paragraphs. - if (is_multi_line(node)) { - real_paragraph.call(this, node, entering); + if (isMultiLine(node)) { + realParagraph.call(this, node, entering); } }; @@ -150,19 +153,26 @@ export default class Markdown { } }; - renderer.html_inline = html_if_tag_allowed; + renderer.html_inline = function(node: commonmark.Node) { + if (isAllowedHtmlTag(node)) { + this.lit(node.literal); + return; + } else { + this.lit(escape(node.literal)); + } + }; - renderer.html_block = function(node) { -/* + renderer.html_block = function(node: commonmark.Node) { + /* // as with `paragraph`, we only insert line breaks // if there are multiple lines in the markdown. const isMultiLine = is_multi_line(node); if (isMultiLine) this.cr(); -*/ - html_if_tag_allowed.call(this, node); -/* + */ + renderer.html_inline(node); + /* if (isMultiLine) this.cr(); -*/ + */ }; return renderer.render(this.parsed); @@ -177,23 +187,22 @@ export default class Markdown { * N.B. this does **NOT** render arbitrary MD to plain text - only MD * which has no formatting. Otherwise it emits HTML(!). */ - toPlaintext() { - const renderer = new commonmark.HtmlRenderer({safe: false}); - const real_paragraph = renderer.paragraph; + toPlaintext(): string { + const renderer = new commonmark.HtmlRenderer({ safe: false }) as CommonmarkHtmlRendererInternal; - renderer.paragraph = function(node, entering) { + renderer.paragraph = function(node: commonmark.Node, entering: boolean) { // as with toHTML, only append lines to paragraphs if there are // multiple paragraphs - if (is_multi_line(node)) { + if (isMultiLine(node)) { if (!entering && node.next) { this.lit('\n\n'); } } }; - renderer.html_block = function(node) { + renderer.html_block = function(node: commonmark.Node) { this.lit(node.literal); - if (is_multi_line(node) && node.next) this.lit('\n\n'); + if (isMultiLine(node) && node.next) this.lit('\n\n'); }; return renderer.render(this.parsed); diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 7db5ed1a4e..3860a5c133 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -17,25 +17,27 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ICreateClientOpts } from 'matrix-js-sdk/src/matrix'; -import {MatrixClient} from 'matrix-js-sdk/src/client'; -import {MemoryStore} from 'matrix-js-sdk/src/store/memory'; +import { ICreateClientOpts, PendingEventOrdering } from 'matrix-js-sdk/src/matrix'; +import { IStartClientOpts, MatrixClient } from 'matrix-js-sdk/src/client'; +import { MemoryStore } from 'matrix-js-sdk/src/store/memory'; import * as utils from 'matrix-js-sdk/src/utils'; -import {EventTimeline} from 'matrix-js-sdk/src/models/event-timeline'; -import {EventTimelineSet} from 'matrix-js-sdk/src/models/event-timeline-set'; +import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline'; +import { EventTimelineSet } from 'matrix-js-sdk/src/models/event-timeline-set'; import * as sdk from './index'; import createMatrixClient from './utils/createMatrixClient'; import SettingsStore from './settings/SettingsStore'; import MatrixActionCreators from './actions/MatrixActionCreators'; import Modal from './Modal'; -import {verificationMethods} from 'matrix-js-sdk/src/crypto'; +import { verificationMethods } from 'matrix-js-sdk/src/crypto'; import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler"; import * as StorageManager from './utils/StorageManager'; import IdentityAuthClient from './IdentityAuthClient'; import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from './SecurityManager'; -import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode"; +import { SHOW_QR_CODE_METHOD } from "matrix-js-sdk/src/crypto/verification/QRCode"; import SecurityCustomisations from "./customisations/Security"; +import { logger } from "matrix-js-sdk/src/logger"; + export interface IMatrixClientCreds { homeserverUrl: string; identityServerUrl: string; @@ -47,25 +49,8 @@ export interface IMatrixClientCreds { freshLogin?: boolean; } -// TODO: Move this to the js-sdk -export interface IOpts { - initialSyncLimit?: number; - pendingEventOrdering?: "detached" | "chronological"; - lazyLoadMembers?: boolean; - clientWellKnownPollPeriod?: number; -} - export interface IMatrixClientPeg { - opts: IOpts; - - /** - * Sets the script href passed to the IndexedDB web worker - * If set, a separate web worker will be started to run the IndexedDB - * queries on. - * - * @param {string} script href to the script to be passed to the web worker - */ - setIndexedDbWorkerScript(script: string): void; + opts: IStartClientOpts; /** * Return the server name of the user's homeserver @@ -122,12 +107,12 @@ export interface IMatrixClientPeg { * This module provides a singleton instance of this class so the 'current' * Matrix Client object is available easily. */ -class _MatrixClientPeg implements IMatrixClientPeg { +class MatrixClientPegClass implements IMatrixClientPeg { // These are the default options used when when the // client is started in 'start'. These can be altered // at any time up to after the 'will_start_client' // event is finished processing. - public opts: IOpts = { + public opts: IStartClientOpts = { initialSyncLimit: 20, }; @@ -141,10 +126,6 @@ class _MatrixClientPeg implements IMatrixClientPeg { constructor() { } - public setIndexedDbWorkerScript(script: string): void { - createMatrixClient.indexedDbWorkerScript = script; - } - public get(): MatrixClient { return this.matrixClient; } @@ -187,7 +168,7 @@ class _MatrixClientPeg implements IMatrixClientPeg { for (const dbType of ['indexeddb', 'memory']) { try { const promise = this.matrixClient.store.startup(); - console.log("MatrixClientPeg: waiting for MatrixClient store to initialise"); + logger.log("MatrixClientPeg: waiting for MatrixClient store to initialise"); await promise; break; } catch (err) { @@ -219,6 +200,7 @@ class _MatrixClientPeg implements IMatrixClientPeg { } catch (e) { if (e && e.name === 'InvalidCryptoStoreError') { // The js-sdk found a crypto DB too new for it to use + // FIXME: Using an import will result in test failures const CryptoStoreTooNewDialog = sdk.getComponent("views.dialogs.CryptoStoreTooNewDialog"); Modal.createDialog(CryptoStoreTooNewDialog); @@ -230,9 +212,10 @@ class _MatrixClientPeg implements IMatrixClientPeg { const opts = utils.deepCopy(this.opts); // the react sdk doesn't work without this, so don't allow - opts.pendingEventOrdering = "detached"; + opts.pendingEventOrdering = PendingEventOrdering.Detached; opts.lazyLoadMembers = true; opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours + opts.experimentalThreadSupport = SettingsStore.getValue("feature_thread"); // Connect the matrix client to the dispatcher and setting handlers MatrixActionCreators.start(this.matrixClient); @@ -244,9 +227,9 @@ class _MatrixClientPeg implements IMatrixClientPeg { public async start(): Promise { const opts = await this.assign(); - console.log(`MatrixClientPeg: really starting MatrixClient`); + logger.log(`MatrixClientPeg: really starting MatrixClient`); await this.get().startClient(opts); - console.log(`MatrixClientPeg: MatrixClient started`); + logger.log(`MatrixClientPeg: MatrixClient started`); } public getCredentials(): IMatrixClientCreds { @@ -320,7 +303,7 @@ class _MatrixClientPeg implements IMatrixClientPeg { } if (!window.mxMatrixClientPeg) { - window.mxMatrixClientPeg = new _MatrixClientPeg(); + window.mxMatrixClientPeg = new MatrixClientPegClass(); } export const MatrixClientPeg = window.mxMatrixClientPeg; diff --git a/src/MediaDeviceHandler.ts b/src/MediaDeviceHandler.ts new file mode 100644 index 0000000000..154f167745 --- /dev/null +++ b/src/MediaDeviceHandler.ts @@ -0,0 +1,125 @@ +/* +Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2021 Šimon Brandner + +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 SettingsStore from "./settings/SettingsStore"; +import { SettingLevel } from "./settings/SettingLevel"; +import EventEmitter from 'events'; +import { MatrixClientPeg } from "./MatrixClientPeg"; + +// XXX: MediaDeviceKind is a union type, so we make our own enum +export enum MediaDeviceKindEnum { + AudioOutput = "audiooutput", + AudioInput = "audioinput", + VideoInput = "videoinput", +} + +export type IMediaDevices = Record>; + +export enum MediaDeviceHandlerEvent { + AudioOutputChanged = "audio_output_changed", +} + +export default class MediaDeviceHandler extends EventEmitter { + private static internalInstance; + + public static get instance(): MediaDeviceHandler { + if (!MediaDeviceHandler.internalInstance) { + MediaDeviceHandler.internalInstance = new MediaDeviceHandler(); + } + return MediaDeviceHandler.internalInstance; + } + + public static async hasAnyLabeledDevices(): Promise { + const devices = await navigator.mediaDevices.enumerateDevices(); + return devices.some(d => Boolean(d.label)); + } + + public static async getDevices(): Promise { + // Only needed for Electron atm, though should work in modern browsers + // once permission has been granted to the webapp + + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const output = { + [MediaDeviceKindEnum.AudioOutput]: [], + [MediaDeviceKindEnum.AudioInput]: [], + [MediaDeviceKindEnum.VideoInput]: [], + }; + + devices.forEach((device) => output[device.kind].push(device)); + return output; + } catch (error) { + console.warn('Unable to refresh WebRTC Devices: ', error); + } + } + + /** + * Retrieves devices from the SettingsStore and tells the js-sdk to use them + */ + public static loadDevices(): void { + const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); + const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); + + MatrixClientPeg.get().getMediaHandler().setAudioInput(audioDeviceId); + MatrixClientPeg.get().getMediaHandler().setVideoInput(videoDeviceId); + } + + public setAudioOutput(deviceId: string): void { + SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId); + this.emit(MediaDeviceHandlerEvent.AudioOutputChanged, deviceId); + } + + /** + * This will not change the device that a potential call uses. The call will + * need to be ended and started again for this change to take effect + * @param {string} deviceId + */ + public setAudioInput(deviceId: string): void { + SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId); + MatrixClientPeg.get().getMediaHandler().setAudioInput(deviceId); + } + + /** + * This will not change the device that a potential call uses. The call will + * need to be ended and started again for this change to take effect + * @param {string} deviceId + */ + public setVideoInput(deviceId: string): void { + SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId); + MatrixClientPeg.get().getMediaHandler().setVideoInput(deviceId); + } + + public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void { + switch (kind) { + case MediaDeviceKindEnum.AudioOutput: this.setAudioOutput(deviceId); break; + case MediaDeviceKindEnum.AudioInput: this.setAudioInput(deviceId); break; + case MediaDeviceKindEnum.VideoInput: this.setVideoInput(deviceId); break; + } + } + + public static getAudioOutput(): string { + return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audiooutput"); + } + + public static getAudioInput(): string { + return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audioinput"); + } + + public static getVideoInput(): string { + return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_videoinput"); + } +} diff --git a/src/Modal.tsx b/src/Modal.tsx index ce11c571b6..1e84078ddb 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -15,14 +15,13 @@ See the License for the specific language governing permissions and limitations under the License. */ - import React from 'react'; import ReactDOM from 'react-dom'; import classNames from 'classnames'; +import { defer } from "matrix-js-sdk/src/utils"; import Analytics from './Analytics'; import dis from './dispatcher/dispatcher'; -import {defer} from './utils/promise'; import AsyncWrapper from './AsyncWrapper'; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; @@ -193,7 +192,7 @@ export class ModalManager { modal.elem = ; modal.close = closeDialog; - return {modal, closeDialog, onFinishedProm}; + return { modal, closeDialog, onFinishedProm }; } private getCloseFn( @@ -282,7 +281,7 @@ export class ModalManager { isStaticModal = false, options: IOptions = {}, ): IHandle { - const {modal, closeDialog, onFinishedProm} = this.buildModal(prom, props, className, options); + const { modal, closeDialog, onFinishedProm } = this.buildModal(prom, props, className, options); if (isPriorityModal) { // XXX: This is destructive this.priorityModal = modal; @@ -305,7 +304,7 @@ export class ModalManager { props?: IProps, className?: string, ): IHandle { - const {modal, closeDialog, onFinishedProm} = this.buildModal(prom, props, className, {}); + const { modal, closeDialog, onFinishedProm } = this.buildModal(prom, props, className, {}); this.modals.push(modal); this.reRender(); @@ -379,13 +378,13 @@ export class ModalManager { const dialog = (

- {modal.elem} + { modal.elem }
); - ReactDOM.render(dialog, ModalManager.getOrCreateContainer()); + setImmediate(() => ReactDOM.render(dialog, ModalManager.getOrCreateContainer())); } else { // This is safe to call repeatedly if we happen to do that ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateContainer()); diff --git a/src/NodeAnimator.js b/src/NodeAnimator.tsx similarity index 63% rename from src/NodeAnimator.js rename to src/NodeAnimator.tsx index 8456e6e9fd..1a8942f5f5 100644 --- a/src/NodeAnimator.js +++ b/src/NodeAnimator.tsx @@ -1,6 +1,21 @@ import React from "react"; import ReactDom from "react-dom"; -import PropTypes from 'prop-types'; + +interface IChildProps { + style: React.CSSProperties; + ref: (node: React.ReactInstance) => void; +} + +interface IProps { + // either a list of child nodes, or a single child. + children: React.ReactNode; + + // optional transition information for changing existing children + transition?: object; + + // a list of state objects to apply to each child node in turn + startStyles: React.CSSProperties[]; +} /** * The NodeAnimator contains components and animates transitions. @@ -9,55 +24,45 @@ import PropTypes from 'prop-types'; * from DOM order. This makes it a lot simpler and lighter: if you need fully * automatic positional animation, look at react-shuffle or similar libraries. */ -export default class NodeAnimator extends React.Component { - static propTypes = { - // either a list of child nodes, or a single child. - children: PropTypes.any, - - // optional transition information for changing existing children - transition: PropTypes.object, - - // a list of state objects to apply to each child node in turn - startStyles: PropTypes.array, - }; - - static defaultProps = { +export default class NodeAnimator extends React.Component { + private nodes = {}; + private children: { [key: string]: React.DetailedReactHTMLElement }; + public static defaultProps: Partial = { startStyles: [], }; - constructor(props) { + constructor(props: IProps) { super(props); - this.nodes = {}; - this._updateChildren(this.props.children); + this.updateChildren(this.props.children); } - componentDidUpdate() { - this._updateChildren(this.props.children); + public componentDidUpdate(): void { + this.updateChildren(this.props.children); } /** * * @param {HTMLElement} node element to apply styles to - * @param {object} styles a key/value pair of CSS properties + * @param {React.CSSProperties} styles a key/value pair of CSS properties * @returns {void} */ - _applyStyles(node, styles) { + private applyStyles(node: HTMLElement, styles: React.CSSProperties): void { Object.entries(styles).forEach(([property, value]) => { node.style[property] = value; }); } - _updateChildren(newChildren) { + private updateChildren(newChildren: React.ReactNode): void { const oldChildren = this.children || {}; this.children = {}; - React.Children.toArray(newChildren).forEach((c) => { + React.Children.toArray(newChildren).forEach((c: any) => { if (oldChildren[c.key]) { const old = oldChildren[c.key]; const oldNode = ReactDom.findDOMNode(this.nodes[old.key]); - if (oldNode && oldNode.style.left !== c.props.style.left) { - this._applyStyles(oldNode, { left: c.props.style.left }); + if (oldNode && (oldNode as HTMLElement).style.left !== c.props.style.left) { + this.applyStyles(oldNode as HTMLElement, { left: c.props.style.left }); // console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left); } // clone the old element with the props (and children) of the new element @@ -66,7 +71,7 @@ export default class NodeAnimator extends React.Component { } else { // new element. If we have a startStyle, use that as the style and go through // the enter animations - const newProps = {}; + const newProps: Partial = {}; const restingStyle = c.props.style; const startStyles = this.props.startStyles; @@ -76,7 +81,7 @@ export default class NodeAnimator extends React.Component { // console.log("mounted@startstyle0: "+JSON.stringify(startStyle)); } - newProps.ref = ((n) => this._collectNode( + newProps.ref = ((n) => this.collectNode( c.key, n, restingStyle, )); @@ -85,7 +90,7 @@ export default class NodeAnimator extends React.Component { }); } - _collectNode(k, node, restingStyle) { + private collectNode(k: string, node: React.ReactInstance, restingStyle: React.CSSProperties): void { if ( node && this.nodes[k] === undefined && @@ -96,7 +101,7 @@ export default class NodeAnimator extends React.Component { // start from startStyle 1: 0 is the one we gave it // to start with, so now we animate 1 etc. for (let i = 1; i < startStyles.length; ++i) { - this._applyStyles(domNode, startStyles[i]); + this.applyStyles(domNode as HTMLElement, startStyles[i]); // console.log("start:" // JSON.stringify(startStyles[i]), // ); @@ -104,7 +109,7 @@ export default class NodeAnimator extends React.Component { // and then we animate to the resting state setTimeout(() => { - this._applyStyles(domNode, restingStyle); + this.applyStyles(domNode as HTMLElement, restingStyle); }, 0); // console.log("enter:", @@ -113,7 +118,7 @@ export default class NodeAnimator extends React.Component { this.nodes[k] = node; } - render() { + public render(): JSX.Element { return ( <>{ Object.values(this.children) } ); diff --git a/src/Notifier.ts b/src/Notifier.ts index 4f55046e72..81c9bf7f4f 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -27,16 +27,18 @@ import * as TextForEvent from './TextForEvent'; import Analytics from './Analytics'; import * as Avatar from './Avatar'; import dis from './dispatcher/dispatcher'; -import * as sdk from './index'; import { _t } from './languageHandler'; import Modal from './Modal'; import SettingsStore from "./settings/SettingsStore"; import { hideToast as hideNotificationsToast } from "./toasts/DesktopNotificationsToast"; -import {SettingLevel} from "./settings/SettingLevel"; -import {isPushNotifyDisabled} from "./settings/controllers/NotificationControllers"; +import { SettingLevel } from "./settings/SettingLevel"; +import { isPushNotifyDisabled } from "./settings/controllers/NotificationControllers"; import RoomViewStore from "./stores/RoomViewStore"; import UserActivity from "./UserActivity"; -import {mediaFromMxc} from "./customisations/Media"; +import { mediaFromMxc } from "./customisations/Media"; +import ErrorDialog from "./components/views/dialogs/ErrorDialog"; + +import { logger } from "matrix-js-sdk/src/logger"; /* * Dispatches: @@ -68,7 +70,7 @@ export const Notifier = { // or not pendingEncryptedEventIds: [], - notificationMessageForEvent: function(ev: MatrixEvent) { + notificationMessageForEvent: function(ev: MatrixEvent): string { if (typehandlers.hasOwnProperty(ev.getContent().msgtype)) { return typehandlers[ev.getContent().msgtype](ev); } @@ -160,7 +162,7 @@ export const Notifier = { _playAudioNotification: async function(ev: MatrixEvent, room: Room) { const sound = this.getSoundForRoom(room.roomId); - console.log(`Got sound ${sound && sound.name || "default"} for ${room.roomId}`); + logger.log(`Got sound ${sound && sound.name || "default"} for ${room.roomId}`); try { const selector = @@ -240,7 +242,6 @@ export const Notifier = { ? _t('%(brand)s does not have permission to send you notifications - ' + 'please check your browser settings', { brand }) : _t('%(brand)s was not given permission to send notifications - please try again', { brand }); - const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); Modal.createTrackedDialog('Unable to enable Notifications', result, ErrorDialog, { title: _t('Unable to enable Notifications'), description, @@ -329,7 +330,7 @@ export const Notifier = { onEvent: function(ev: MatrixEvent) { if (!this.isSyncing) return; // don't alert for any messages initially - if (ev.sender && ev.sender.userId === MatrixClientPeg.get().credentials.userId) return; + if (ev.getSender() === MatrixClientPeg.get().credentials.userId) return; MatrixClientPeg.get().decryptEventIfNeeded(ev); diff --git a/src/PageTypes.js b/src/PageTypes.ts similarity index 74% rename from src/PageTypes.js rename to src/PageTypes.ts index 09e0eadbd7..73967f351e 100644 --- a/src/PageTypes.js +++ b/src/PageTypes.ts @@ -16,11 +16,13 @@ limitations under the License. */ /** The types of page which can be shown by the LoggedInView */ -export default { - HomePage: "home_page", - RoomView: "room_view", - RoomDirectory: "room_directory", - UserView: "user_view", - GroupView: "group_view", - MyGroups: "my_groups", -}; +enum PageType { + HomePage = "home_page", + RoomView = "room_view", + RoomDirectory = "room_directory", + UserView = "user_view", + GroupView = "group_view", + MyGroups = "my_groups", +} + +export default PageType; diff --git a/src/PasswordReset.js b/src/PasswordReset.ts similarity index 89% rename from src/PasswordReset.js rename to src/PasswordReset.ts index 88ae00d088..76f54de245 100644 --- a/src/PasswordReset.js +++ b/src/PasswordReset.ts @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { createClient } from 'matrix-js-sdk/src/matrix'; +import { createClient, IRequestTokenResponse, MatrixClient } from 'matrix-js-sdk/src/matrix'; import { _t } from './languageHandler'; /** @@ -26,12 +26,18 @@ import { _t } from './languageHandler'; * API on the homeserver in question with the new password. */ export default class PasswordReset { + private client: MatrixClient; + private clientSecret: string; + private identityServerDomain: string; + private password: string; + private sessionId: string; + /** * Configure the endpoints for password resetting. * @param {string} homeserverUrl The URL to the HS which has the account to reset. * @param {string} identityUrl The URL to the IS which has linked the email -> mxid mapping. */ - constructor(homeserverUrl, identityUrl) { + constructor(homeserverUrl: string, identityUrl: string) { this.client = createClient({ baseUrl: homeserverUrl, idBaseUrl: identityUrl, @@ -47,7 +53,7 @@ export default class PasswordReset { * @param {string} newPassword The new password for the account. * @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked(). */ - resetPassword(emailAddress, newPassword) { + public resetPassword(emailAddress: string, newPassword: string): Promise { this.password = newPassword; return this.client.requestPasswordEmailToken(emailAddress, this.clientSecret, 1).then((res) => { this.sessionId = res.sid; @@ -69,7 +75,7 @@ export default class PasswordReset { * with a "message" property which contains a human-readable message detailing why * the reset failed, e.g. "There is no mapped matrix user ID for the given email address". */ - async checkEmailLinkClicked() { + public async checkEmailLinkClicked(): Promise { const creds = { sid: this.sessionId, client_secret: this.clientSecret, diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts new file mode 100644 index 0000000000..bdc0814b5d --- /dev/null +++ b/src/PosthogAnalytics.ts @@ -0,0 +1,361 @@ +/* +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 posthog, { PostHog } from 'posthog-js'; +import PlatformPeg from './PlatformPeg'; +import SdkConfig from './SdkConfig'; +import SettingsStore from './settings/SettingsStore'; +import { MatrixClientPeg } from "./MatrixClientPeg"; +import { MatrixClient } from "matrix-js-sdk/src/client"; + +import { logger } from "matrix-js-sdk/src/logger"; + +/* Posthog analytics tracking. + * + * Anonymity behaviour is as follows: + * + * - If Posthog isn't configured in `config.json`, events are not sent. + * - If [Do Not Track](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/doNotTrack) is + * enabled, events are not sent (this detection is built into posthog and turned on via the + * `respect_dnt` flag being passed to `posthog.init`). + * - If the `feature_pseudonymous_analytics_opt_in` labs flag is `true`, track pseudonomously by maintaining + * a randomised analytics ID in account_data for that user (shared between devices) and sending it to posthog to + identify the user. + * - Otherwise, if the existing `analyticsOptIn` flag is `true`, track anonymously, i.e. do not identify the user + using any identifier that would be consistent across devices. + * - If both flags are false or not set, events are not sent. + */ + +interface IEvent { + // The event name that will be used by PostHog. Event names should use snake_case. + eventName: string; + + // The properties of the event that will be stored in PostHog. This is just a placeholder, + // extending interfaces must override this with a concrete definition to do type validation. + properties: {}; +} + +export enum Anonymity { + Disabled, + Anonymous, + Pseudonymous +} + +// If an event extends IPseudonymousEvent, the event contains pseudonymous data +// that won't be sent unless the user has explicitly consented to pseudonymous tracking. +// For example, it might contain hashed user IDs or room IDs. +// Such events will be automatically dropped if PosthogAnalytics.anonymity isn't set to Pseudonymous. +export interface IPseudonymousEvent extends IEvent {} + +// If an event extends IAnonymousEvent, the event strictly contains *only* anonymous data; +// i.e. no identifiers that can be associated with the user. +export interface IAnonymousEvent extends IEvent {} + +export interface IRoomEvent extends IPseudonymousEvent { + hashedRoomId: string; +} + +interface IPageView extends IAnonymousEvent { + eventName: "$pageview"; + properties: { + durationMs?: number; + screen?: string; + }; +} + +const whitelistedScreens = new Set([ + "register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory", + "start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group", +]); + +export async function getRedactedCurrentLocation( + origin: string, + hash: string, + pathname: string, + anonymity: Anonymity, +): Promise { + // Redact PII from the current location. + // For known screens, assumes a URL structure of //might/be/pii + if (origin.startsWith('file://')) { + pathname = "//"; + } + + let hashStr; + if (hash == "") { + hashStr = ""; + } else { + let [beforeFirstSlash, screen] = hash.split("/"); + + if (!whitelistedScreens.has(screen)) { + screen = ""; + } + + hashStr = `${beforeFirstSlash}/${screen}/`; + } + return origin + pathname + hashStr; +} + +interface PlatformProperties { + appVersion: string; + appPlatform: string; +} + +export class PosthogAnalytics { + /* Wrapper for Posthog analytics. + * 3 modes of anonymity are supported, governed by this.anonymity + * - Anonymity.Disabled means *no data* is passed to posthog + * - Anonymity.Anonymous means no identifier is passed to posthog + * - Anonymity.Pseudonymous means an analytics ID stored in account_data and shared between devices + * is passed to posthog. + * + * To update anonymity, call updateAnonymityFromSettings() or you can set it directly via setAnonymity(). + * + * To pass an event to Posthog: + * + * 1. Declare a type for the event, extending IAnonymousEvent or IPseudonymousEvent. + * 2. Call the appropriate track*() method. Pseudonymous events will be dropped when anonymity is + * Anonymous or Disabled; Anonymous events will be dropped when anonymity is Disabled. + */ + + private anonymity = Anonymity.Disabled; + // set true during the constructor if posthog config is present, otherwise false + private enabled = false; + private static _instance = null; + private platformSuperProperties = {}; + private static ANALYTICS_ID_EVENT_TYPE = "im.vector.web.analytics_id"; + + public static get instance(): PosthogAnalytics { + if (!this._instance) { + this._instance = new PosthogAnalytics(posthog); + } + return this._instance; + } + + constructor(private readonly posthog: PostHog) { + const posthogConfig = SdkConfig.get()["posthog"]; + if (posthogConfig) { + this.posthog.init(posthogConfig.projectApiKey, { + api_host: posthogConfig.apiHost, + autocapture: false, + mask_all_text: true, + mask_all_element_attributes: true, + // This only triggers on page load, which for our SPA isn't particularly useful. + // Plus, the .capture call originating from somewhere in posthog makes it hard + // to redact URLs, which requires async code. + // + // To raise this manually, just call .capture("$pageview") or posthog.capture_pageview. + capture_pageview: false, + sanitize_properties: this.sanitizeProperties, + respect_dnt: true, + }); + this.enabled = true; + } else { + this.enabled = false; + } + } + + private sanitizeProperties = (properties: posthog.Properties): posthog.Properties => { + // Callback from posthog to sanitize properties before sending them to the server. + // + // Here we sanitize posthog's built in properties which leak PII e.g. url reporting. + // See utils.js _.info.properties in posthog-js. + + // Replace the $current_url with a redacted version. + // $redacted_current_url is injected by this class earlier in capture(), as its generation + // is async and can't be done in this non-async callback. + if (!properties['$redacted_current_url']) { + logger.log("$redacted_current_url not set in sanitizeProperties, will drop $current_url entirely"); + } + properties['$current_url'] = properties['$redacted_current_url']; + delete properties['$redacted_current_url']; + + if (this.anonymity == Anonymity.Anonymous) { + // drop referrer information for anonymous users + properties['$referrer'] = null; + properties['$referring_domain'] = null; + properties['$initial_referrer'] = null; + properties['$initial_referring_domain'] = null; + + // drop device ID, which is a UUID persisted in local storage + properties['$device_id'] = null; + } + + return properties; + }; + + private static getAnonymityFromSettings(): Anonymity { + // determine the current anonymity level based on current user settings + + // "Send anonymous usage data which helps us improve Element. This will use a cookie." + const analyticsOptIn = SettingsStore.getValue("analyticsOptIn", null, true); + + // (proposed wording) "Send pseudonymous usage data which helps us improve Element. This will use a cookie." + // + // TODO: Currently, this is only a labs flag, for testing purposes. + const pseudonumousOptIn = SettingsStore.getValue("feature_pseudonymous_analytics_opt_in", null, true); + + let anonymity; + if (pseudonumousOptIn) { + anonymity = Anonymity.Pseudonymous; + } else if (analyticsOptIn) { + anonymity = Anonymity.Anonymous; + } else { + anonymity = Anonymity.Disabled; + } + + return anonymity; + } + + private registerSuperProperties(properties: posthog.Properties) { + if (this.enabled) { + this.posthog.register(properties); + } + } + + private static async getPlatformProperties(): Promise { + const platform = PlatformPeg.get(); + let appVersion; + try { + appVersion = await platform.getAppVersion(); + } catch (e) { + // this happens if no version is set i.e. in dev + appVersion = "unknown"; + } + + return { + appVersion, + appPlatform: platform.getHumanReadableName(), + }; + } + + private async capture(eventName: string, properties: posthog.Properties) { + if (!this.enabled) { + return; + } + const { origin, hash, pathname } = window.location; + properties['$redacted_current_url'] = await getRedactedCurrentLocation( + origin, hash, pathname, this.anonymity); + this.posthog.capture(eventName, properties); + } + + public isEnabled(): boolean { + return this.enabled; + } + + public setAnonymity(anonymity: Anonymity): void { + // Update this.anonymity. + // This is public for testing purposes, typically you want to call updateAnonymityFromSettings + // to ensure this value is in step with the user's settings. + if (this.enabled && (anonymity == Anonymity.Disabled || anonymity == Anonymity.Anonymous)) { + // when transitioning to Disabled or Anonymous ensure we clear out any prior state + // set in posthog e.g. distinct ID + this.posthog.reset(); + // Restore any previously set platform super properties + this.registerSuperProperties(this.platformSuperProperties); + } + this.anonymity = anonymity; + } + + private static getRandomAnalyticsId(): string { + return [...crypto.getRandomValues(new Uint8Array(16))].map((c) => c.toString(16)).join(''); + } + + public async identifyUser(client: MatrixClient, analyticsIdGenerator: () => string): Promise { + if (this.anonymity == Anonymity.Pseudonymous) { + // Check the user's account_data for an analytics ID to use. Storing the ID in account_data allows + // different devices to send the same ID. + try { + const accountData = await client.getAccountDataFromServer(PosthogAnalytics.ANALYTICS_ID_EVENT_TYPE); + let analyticsID = accountData?.id; + if (!analyticsID) { + // Couldn't retrieve an analytics ID from user settings, so create one and set it on the server. + // Note there's a race condition here - if two devices do these steps at the same time, last write + // wins, and the first writer will send tracking with an ID that doesn't match the one on the server + // until the next time account data is refreshed and this function is called (most likely on next + // page load). This will happen pretty infrequently, so we can tolerate the possibility. + analyticsID = analyticsIdGenerator(); + await client.setAccountData("im.vector.web.analytics_id", { id: analyticsID }); + } + this.posthog.identify(analyticsID); + } catch (e) { + // The above could fail due to network requests, but not essential to starting the application, + // so swallow it. + logger.log("Unable to identify user for tracking" + e.toString()); + } + } + } + + public getAnonymity(): Anonymity { + return this.anonymity; + } + + public logout(): void { + if (this.enabled) { + this.posthog.reset(); + } + this.setAnonymity(Anonymity.Anonymous); + } + + public async trackPseudonymousEvent( + eventName: E["eventName"], + properties: E["properties"] = {}, + ) { + if (this.anonymity == Anonymity.Anonymous || this.anonymity == Anonymity.Disabled) return; + await this.capture(eventName, properties); + } + + public async trackAnonymousEvent( + eventName: E["eventName"], + properties: E["properties"] = {}, + ): Promise { + if (this.anonymity == Anonymity.Disabled) return; + await this.capture(eventName, properties); + } + + public async trackPageView(durationMs: number): Promise { + const hash = window.location.hash; + + let screen = null; + const split = hash.split("/"); + if (split.length >= 2) { + screen = split[1]; + } + + await this.trackAnonymousEvent("$pageview", { + durationMs, + screen, + }); + } + + public async updatePlatformSuperProperties(): Promise { + // Update super properties in posthog with our platform (app version, platform). + // These properties will be subsequently passed in every event. + // + // This only needs to be done once per page lifetime. Note that getPlatformProperties + // is async and can involve a network request if we are running in a browser. + this.platformSuperProperties = await PosthogAnalytics.getPlatformProperties(); + this.registerSuperProperties(this.platformSuperProperties); + } + + public async updateAnonymityFromSettings(userId?: string): Promise { + // Update this.anonymity based on the user's analytics opt-in settings + // Identify the user (via hashed user ID) to posthog if anonymity is pseudonmyous + this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings()); + if (userId && this.getAnonymity() == Anonymity.Pseudonymous) { + await this.identifyUser(MatrixClientPeg.get(), PosthogAnalytics.getRandomAnalyticsId); + } + } +} diff --git a/src/Presence.ts b/src/Presence.ts index 8f2e127cb4..af35060363 100644 --- a/src/Presence.ts +++ b/src/Presence.ts @@ -16,10 +16,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from "./MatrixClientPeg"; +import { MatrixClientPeg } from "./MatrixClientPeg"; import dis from "./dispatcher/dispatcher"; import Timer from './utils/Timer'; -import {ActionPayload} from "./dispatcher/payloads"; +import { ActionPayload } from "./dispatcher/payloads"; // Time in ms after that a user is considered as unavailable/away const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins @@ -78,7 +78,7 @@ class Presence { this.setState(State.Online); this.unavailableTimer.restart(); } - } + }; /** * Set the presence state. @@ -98,7 +98,7 @@ class Presence { } try { - await MatrixClientPeg.get().setPresence({presence: this.state}); + await MatrixClientPeg.get().setPresence({ presence: this.state }); console.info("Presence:", newState); } catch (err) { console.error("Failed to set presence:", err); diff --git a/src/Registration.js b/src/Registration.tsx similarity index 71% rename from src/Registration.js rename to src/Registration.tsx index 0df2ec3eb3..90e81c0d45 100644 --- a/src/Registration.js +++ b/src/Registration.tsx @@ -20,10 +20,11 @@ limitations under the License. * registration code. */ +import React from "react"; import dis from './dispatcher/dispatcher'; -import * as sdk from './index'; import Modal from './Modal'; import { _t } from './languageHandler'; +import QuestionDialog from "./components/views/dialogs/QuestionDialog"; // Regex for what a "safe" or "Matrix-looking" localpart would be. // TODO: Update as needed for https://github.com/matrix-org/matrix-doc/issues/1514 @@ -41,9 +42,11 @@ export const SAFE_LOCALPART_REGEX = /^[a-z0-9=_\-./]+$/; * @param {bool} options.screen_after * If present the screen to redirect to after a successful login or register. */ -export async function startAnyRegistrationFlow(options) { +export async function startAnyRegistrationFlow( + // eslint-disable-next-line camelcase + options: { go_home_on_cancel?: boolean, go_welcome_on_cancel?: boolean, screen_after?: boolean}, +): Promise { if (options === undefined) options = {}; - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, { hasCancelButton: true, quitOnly: true, @@ -51,18 +54,23 @@ export async function startAnyRegistrationFlow(options) { description: _t("Use your account or create a new one to continue."), button: _t("Create Account"), extraButtons: [ - , + , ], onFinished: (proceed) => { if (proceed) { - dis.dispatch({action: 'start_registration', screenAfterLogin: options.screen_after}); + dis.dispatch({ action: 'start_registration', screenAfterLogin: options.screen_after }); } else if (options.go_home_on_cancel) { - dis.dispatch({action: 'view_home_page'}); + dis.dispatch({ action: 'view_home_page' }); } else if (options.go_welcome_on_cancel) { - dis.dispatch({action: 'view_welcome_page'}); + dis.dispatch({ action: 'view_welcome_page' }); } }, }); diff --git a/src/Resend.js b/src/Resend.ts similarity index 63% rename from src/Resend.js rename to src/Resend.ts index f1e5fb38f5..0b5c279165 100644 --- a/src/Resend.js +++ b/src/Resend.ts @@ -1,6 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2015-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. @@ -15,47 +14,46 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from './MatrixClientPeg'; +import { MatrixEvent, EventStatus } from 'matrix-js-sdk/src/models/event'; +import { Room } from 'matrix-js-sdk/src/models/room'; + +import { MatrixClientPeg } from './MatrixClientPeg'; import dis from './dispatcher/dispatcher'; -import { EventStatus } from 'matrix-js-sdk/src/models/event'; + +import { logger } from "matrix-js-sdk/src/logger"; export default class Resend { - static resendUnsentEvents(room) { - return Promise.all(room.getPendingEvents().filter(function(ev) { + static resendUnsentEvents(room: Room): Promise { + return Promise.all(room.getPendingEvents().filter(function(ev: MatrixEvent) { return ev.status === EventStatus.NOT_SENT; - }).map(function(event) { + }).map(function(event: MatrixEvent) { return Resend.resend(event); })); } - static cancelUnsentEvents(room) { - room.getPendingEvents().filter(function(ev) { + static cancelUnsentEvents(room: Room): void { + room.getPendingEvents().filter(function(ev: MatrixEvent) { return ev.status === EventStatus.NOT_SENT; - }).forEach(function(event) { + }).forEach(function(event: MatrixEvent) { Resend.removeFromQueue(event); }); } - static resend(event) { + static resend(event: MatrixEvent): Promise { const room = MatrixClientPeg.get().getRoom(event.getRoomId()); return MatrixClientPeg.get().resendEvent(event, room).then(function(res) { dis.dispatch({ action: 'message_sent', event: event, }); - }, function(err) { + }, function(err: Error) { // XXX: temporary logging to try to diagnose // https://github.com/vector-im/element-web/issues/3148 - console.log('Resend got send failure: ' + err.name + '(' + err + ')'); - - dis.dispatch({ - action: 'message_send_failed', - event: event, - }); + logger.log('Resend got send failure: ' + err.name + '(' + err + ')'); }); } - static removeFromQueue(event) { + static removeFromQueue(event: MatrixEvent): void { MatrixClientPeg.get().cancelPendingEvent(event); } } diff --git a/src/Roles.ts b/src/Roles.ts index b4be97fdce..ae0d316d30 100644 --- a/src/Roles.ts +++ b/src/Roles.ts @@ -31,6 +31,6 @@ export function textualPowerLevel(level: number, usersDefault: number): string { if (LEVEL_ROLE_MAP[level]) { return LEVEL_ROLE_MAP[level]; } else { - return _t("Custom (%(level)s)", {level}); + return _t("Custom (%(level)s)", { level }); } } diff --git a/src/RoomAliasCache.js b/src/RoomAliasCache.ts similarity index 81% rename from src/RoomAliasCache.js rename to src/RoomAliasCache.ts index bb511ba4d7..c318db2d3f 100644 --- a/src/RoomAliasCache.js +++ b/src/RoomAliasCache.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 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. @@ -24,12 +24,12 @@ limitations under the License. * A similar thing could also be achieved via `pushState` with a state object, * but keeping it separate like this seems easier in case we do want to extend. */ -const aliasToIDMap = new Map(); +const aliasToIDMap = new Map(); -export function storeRoomAliasInCache(alias, id) { +export function storeRoomAliasInCache(alias: string, id: string): void { aliasToIDMap.set(alias, id); } -export function getCachedRoomIDForAlias(alias) { +export function getCachedRoomIDForAlias(alias: string): string { return aliasToIDMap.get(alias); } diff --git a/src/RoomInvite.js b/src/RoomInvite.tsx similarity index 50% rename from src/RoomInvite.js rename to src/RoomInvite.tsx index aa758ecbdc..7d093f4092 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.tsx @@ -1,7 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2017, 2018 New Vector Ltd -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2016 - 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. @@ -16,15 +14,26 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import {MatrixClientPeg} from './MatrixClientPeg'; -import MultiInviter from './utils/MultiInviter'; +import React from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { User } from "matrix-js-sdk/src/models/user"; + +import { MatrixClientPeg } from './MatrixClientPeg'; +import MultiInviter, { CompletionStates } from './utils/MultiInviter'; import Modal from './Modal'; -import * as sdk from './'; import { _t } from './languageHandler'; -import InviteDialog, {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog"; +import InviteDialog, { KIND_DM, KIND_INVITE, Member } from "./components/views/dialogs/InviteDialog"; import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog"; -import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore"; +import { CommunityPrototypeStore } from "./stores/CommunityPrototypeStore"; +import BaseAvatar from "./components/views/avatars/BaseAvatar"; +import { mediaFromMxc } from "./customisations/Media"; +import ErrorDialog from "./components/views/dialogs/ErrorDialog"; + +export interface IInviteResult { + states: CompletionStates; + inviter: MultiInviter; +} /** * Invites multiple addresses to a room @@ -32,24 +41,23 @@ import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore"; * no option to cancel. * * @param {string} roomId The ID of the room to invite to - * @param {string[]} addrs Array of strings of addresses to invite. May be matrix IDs or 3pids. + * @param {string[]} addresses Array of strings of addresses to invite. May be matrix IDs or 3pids. * @returns {Promise} Promise */ -export function inviteMultipleToRoom(roomId, addrs) { +export function inviteMultipleToRoom(roomId: string, addresses: string[]): Promise { const inviter = new MultiInviter(roomId); - return inviter.invite(addrs).then(states => Promise.resolve({states, inviter})); + return inviter.invite(addresses).then(states => Promise.resolve({ states, inviter })); } -export function showStartChatInviteDialog(initialText) { +export function showStartChatInviteDialog(initialText = ""): void { // This dialog handles the room creation internally - we don't need to worry about it. - const InviteDialog = sdk.getComponent("dialogs.InviteDialog"); Modal.createTrackedDialog( - 'Start DM', '', InviteDialog, {kind: KIND_DM, initialText}, + 'Start DM', '', InviteDialog, { kind: KIND_DM, initialText }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, ); } -export function showRoomInviteDialog(roomId, initialText = "") { +export function showRoomInviteDialog(roomId: string, initialText = ""): void { // This dialog handles the room creation internally - we don't need to worry about it. Modal.createTrackedDialog( "Invite Users", "", InviteDialog, { @@ -61,14 +69,14 @@ export function showRoomInviteDialog(roomId, initialText = "") { ); } -export function showCommunityRoomInviteDialog(roomId, communityName) { +export function showCommunityRoomInviteDialog(roomId: string, communityName: string): void { Modal.createTrackedDialog( - 'Invite Users to Community', '', CommunityPrototypeInviteDialog, {communityName, roomId}, + 'Invite Users to Community', '', CommunityPrototypeInviteDialog, { communityName, roomId }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, ); } -export function showCommunityInviteDialog(communityId) { +export function showCommunityInviteDialog(communityId: string): void { const chat = CommunityPrototypeStore.instance.getGeneralChat(communityId); if (chat) { const name = CommunityPrototypeStore.instance.getCommunityName(communityId); @@ -83,7 +91,7 @@ export function showCommunityInviteDialog(communityId) { * @param {MatrixEvent} event The event to check * @returns {boolean} True if valid, false otherwise */ -export function isValid3pidInvite(event) { +export function isValid3pidInvite(event: MatrixEvent): boolean { if (!event || event.getType() !== "m.room.third_party_invite") return false; // any events without these keys are not valid 3pid invites, so we ignore them @@ -96,13 +104,12 @@ export function isValid3pidInvite(event) { return true; } -export function inviteUsersToRoom(roomId, userIds) { +export function inviteUsersToRoom(roomId: string, userIds: string[]): Promise { return inviteMultipleToRoom(roomId, userIds).then((result) => { const room = MatrixClientPeg.get().getRoom(roomId); showAnyInviteErrors(result.states, room, result.inviter); }).catch((err) => { console.error(err.stack); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, { title: _t("Failed to invite"), description: ((err && err.message) ? err.message : _t("Operation failed")), @@ -110,35 +117,66 @@ export function inviteUsersToRoom(roomId, userIds) { }); } -export function showAnyInviteErrors(addrs, room, inviter) { +export function showAnyInviteErrors( + states: CompletionStates, + room: Room, + inviter: MultiInviter, + userMap?: Map, +): boolean { // Show user any errors - const failedUsers = Object.keys(addrs).filter(a => addrs[a] === 'error'); + const failedUsers = Object.keys(states).filter(a => states[a] === 'error'); if (failedUsers.length === 1 && inviter.fatal) { // Just get the first message because there was a fatal problem on the first // user. This usually means that no other users were attempted, making it // pointless for us to list who failed exactly. - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to invite users to the room', '', ErrorDialog, { - title: _t("Failed to invite users to the room:", {roomName: room.name}), + title: _t("Failed to invite users to the room:", { roomName: room.name }), description: inviter.getErrorText(failedUsers[0]), }); return false; } else { const errorList = []; for (const addr of failedUsers) { - if (addrs[addr] === "error") { + if (states[addr] === "error") { const reason = inviter.getErrorText(addr); errorList.push(addr + ": " + reason); } } + const cli = MatrixClientPeg.get(); if (errorList.length > 0) { // React 16 doesn't let us use `errorList.join(
)` anymore, so this is our solution - const description =
{errorList.map(e =>
{e}
)}
; + const description =
+

{ _t("We sent the others, but the below people couldn't be invited to ", {}, { + RoomName: () => { room.name }, + }) }

+
+ { failedUsers.map(addr => { + const user = userMap?.get(addr) || cli.getUser(addr); + const name = (user as Member).name || (user as User).rawDisplayName; + const avatarUrl = (user as Member).getMxcAvatarUrl?.() || (user as User).avatarUrl; + return
+
+ + { name } + { user.userId } +
+
+ { inviter.getErrorText(addr) } +
+
; + }) } +
+
; - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Failed to invite the following users to the room', '', ErrorDialog, { - title: _t("Failed to invite the following users to the %(roomName)s room:", {roomName: room.name}), + Modal.createTrackedDialog("Some invites could not be sent", "", ErrorDialog, { + title: _t("Some invites couldn't be sent"), description, }); return false; diff --git a/src/RoomNotifs.js b/src/RoomNotifs.ts similarity index 65% rename from src/RoomNotifs.js rename to src/RoomNotifs.ts index 600655f635..5abee9a6ad 100644 --- a/src/RoomNotifs.js +++ b/src/RoomNotifs.ts @@ -15,29 +15,33 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from './MatrixClientPeg'; -import {PushProcessor} from 'matrix-js-sdk/src/pushprocessor'; +import { MatrixClientPeg } from './MatrixClientPeg'; +import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor'; +import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; +import { IAnnotatedPushRule, PushRuleKind } from "matrix-js-sdk/src/@types/PushRules"; -export const ALL_MESSAGES_LOUD = 'all_messages_loud'; -export const ALL_MESSAGES = 'all_messages'; -export const MENTIONS_ONLY = 'mentions_only'; -export const MUTE = 'mute'; +export enum RoomNotifState { + AllMessagesLoud = 'all_messages_loud', + AllMessages = 'all_messages', + MentionsOnly = 'mentions_only', + Mute = 'mute', +} -export const BADGE_STATES = [ALL_MESSAGES, ALL_MESSAGES_LOUD]; -export const MENTION_BADGE_STATES = [...BADGE_STATES, MENTIONS_ONLY]; +export const BADGE_STATES = [RoomNotifState.AllMessages, RoomNotifState.AllMessagesLoud]; +export const MENTION_BADGE_STATES = [...BADGE_STATES, RoomNotifState.MentionsOnly]; -export function shouldShowNotifBadge(roomNotifState) { +export function shouldShowNotifBadge(roomNotifState: RoomNotifState): boolean { return BADGE_STATES.includes(roomNotifState); } -export function shouldShowMentionBadge(roomNotifState) { +export function shouldShowMentionBadge(roomNotifState: RoomNotifState): boolean { return MENTION_BADGE_STATES.includes(roomNotifState); } -export function aggregateNotificationCount(rooms) { - return rooms.reduce((result, room) => { +export function aggregateNotificationCount(rooms: Room[]): {count: number, highlight: boolean} { + return rooms.reduce<{count: number, highlight: boolean}>((result, room) => { const roomNotifState = getRoomNotifsState(room.roomId); - const highlight = room.getUnreadNotificationCount('highlight') > 0; + const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight) > 0; // use helper method to include highlights in the previous version of the room const notificationCount = getUnreadNotificationCount(room); @@ -52,12 +56,12 @@ export function aggregateNotificationCount(rooms) { } } return result; - }, {count: 0, highlight: false}); + }, { count: 0, highlight: false }); } -export function getRoomHasBadge(room) { +export function getRoomHasBadge(room: Room): boolean { const roomNotifState = getRoomNotifsState(room.roomId); - const highlight = room.getUnreadNotificationCount('highlight') > 0; + const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight) > 0; const notificationCount = room.getUnreadNotificationCount(); const notifBadges = notificationCount > 0 && shouldShowNotifBadge(roomNotifState); @@ -66,14 +70,14 @@ export function getRoomHasBadge(room) { return notifBadges || mentionBadges; } -export function getRoomNotifsState(roomId) { - if (MatrixClientPeg.get().isGuest()) return ALL_MESSAGES; +export function getRoomNotifsState(roomId: string): RoomNotifState { + if (MatrixClientPeg.get().isGuest()) return RoomNotifState.AllMessages; // look through the override rules for a rule affecting this room: // if one exists, it will take precedence. const muteRule = findOverrideMuteRule(roomId); if (muteRule) { - return MUTE; + return RoomNotifState.Mute; } // for everything else, look at the room rule. @@ -89,27 +93,27 @@ export function getRoomNotifsState(roomId) { // XXX: We have to assume the default is to notify for all messages // (in particular this will be 'wrong' for one to one rooms because // they will notify loudly for all messages) - if (!roomRule || !roomRule.enabled) return ALL_MESSAGES; + if (!roomRule || !roomRule.enabled) return RoomNotifState.AllMessages; // a mute at the room level will still allow mentions // to notify - if (isMuteRule(roomRule)) return MENTIONS_ONLY; + if (isMuteRule(roomRule)) return RoomNotifState.MentionsOnly; const actionsObject = PushProcessor.actionListToActionsObject(roomRule.actions); - if (actionsObject.tweaks.sound) return ALL_MESSAGES_LOUD; + if (actionsObject.tweaks.sound) return RoomNotifState.AllMessagesLoud; return null; } -export function setRoomNotifsState(roomId, newState) { - if (newState === MUTE) { +export function setRoomNotifsState(roomId: string, newState: RoomNotifState): Promise { + if (newState === RoomNotifState.Mute) { return setRoomNotifsStateMuted(roomId); } else { return setRoomNotifsStateUnmuted(roomId, newState); } } -export function getUnreadNotificationCount(room, type=null) { +export function getUnreadNotificationCount(room: Room, type: NotificationCountType = null): number { let notificationCount = room.getUnreadNotificationCount(type); // Check notification counts in the old room just in case there's some lost @@ -124,21 +128,21 @@ export function getUnreadNotificationCount(room, type=null) { // notifying the user for unread messages because they would have extreme // difficulty changing their notification preferences away from "All Messages" // and "Noisy". - notificationCount += oldRoom.getUnreadNotificationCount("highlight"); + notificationCount += oldRoom.getUnreadNotificationCount(NotificationCountType.Highlight); } } return notificationCount; } -function setRoomNotifsStateMuted(roomId) { +function setRoomNotifsStateMuted(roomId: string): Promise { const cli = MatrixClientPeg.get(); const promises = []; // delete the room rule const roomRule = cli.getRoomPushRule('global', roomId); if (roomRule) { - promises.push(cli.deletePushRule('global', 'room', roomRule.rule_id)); + promises.push(cli.deletePushRule('global', PushRuleKind.RoomSpecific, roomRule.rule_id)); } // add/replace an override rule to squelch everything in this room @@ -146,7 +150,7 @@ function setRoomNotifsStateMuted(roomId) { // is an override rule, not a room rule: it still pertains to this room // though, so using the room ID as the rule ID is logical and prevents // duplicate copies of the rule. - promises.push(cli.addPushRule('global', 'override', roomId, { + promises.push(cli.addPushRule('global', PushRuleKind.Override, roomId, { conditions: [ { kind: 'event_match', @@ -162,30 +166,30 @@ function setRoomNotifsStateMuted(roomId) { return Promise.all(promises); } -function setRoomNotifsStateUnmuted(roomId, newState) { +function setRoomNotifsStateUnmuted(roomId: string, newState: RoomNotifState): Promise { const cli = MatrixClientPeg.get(); const promises = []; const overrideMuteRule = findOverrideMuteRule(roomId); if (overrideMuteRule) { - promises.push(cli.deletePushRule('global', 'override', overrideMuteRule.rule_id)); + promises.push(cli.deletePushRule('global', PushRuleKind.Override, overrideMuteRule.rule_id)); } - if (newState === 'all_messages') { + if (newState === RoomNotifState.AllMessages) { const roomRule = cli.getRoomPushRule('global', roomId); if (roomRule) { - promises.push(cli.deletePushRule('global', 'room', roomRule.rule_id)); + promises.push(cli.deletePushRule('global', PushRuleKind.RoomSpecific, roomRule.rule_id)); } - } else if (newState === 'mentions_only') { - promises.push(cli.addPushRule('global', 'room', roomId, { + } else if (newState === RoomNotifState.MentionsOnly) { + promises.push(cli.addPushRule('global', PushRuleKind.RoomSpecific, roomId, { actions: [ 'dont_notify', ], })); // https://matrix.org/jira/browse/SPEC-400 - promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true)); - } else if ('all_messages_loud') { - promises.push(cli.addPushRule('global', 'room', roomId, { + promises.push(cli.setPushRuleEnabled('global', PushRuleKind.RoomSpecific, roomId, true)); + } else if (newState === RoomNotifState.AllMessagesLoud) { + promises.push(cli.addPushRule('global', PushRuleKind.RoomSpecific, roomId, { actions: [ 'notify', { @@ -195,13 +199,13 @@ function setRoomNotifsStateUnmuted(roomId, newState) { ], })); // https://matrix.org/jira/browse/SPEC-400 - promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true)); + promises.push(cli.setPushRuleEnabled('global', PushRuleKind.RoomSpecific, roomId, true)); } return Promise.all(promises); } -function findOverrideMuteRule(roomId) { +function findOverrideMuteRule(roomId: string): IAnnotatedPushRule { const cli = MatrixClientPeg.get(); if (!cli.pushRules || !cli.pushRules['global'] || @@ -218,7 +222,7 @@ function findOverrideMuteRule(roomId) { return null; } -function isRuleForRoom(roomId, rule) { +function isRuleForRoom(roomId: string, rule: IAnnotatedPushRule): boolean { if (rule.conditions.length !== 1) { return false; } @@ -226,6 +230,6 @@ function isRuleForRoom(roomId, rule) { return (cond.kind === 'event_match' && cond.key === 'room_id' && cond.pattern === roomId); } -function isMuteRule(rule) { +function isMuteRule(rule: IAnnotatedPushRule): boolean { return (rule.actions.length === 1 && rule.actions[0] === 'dont_notify'); } diff --git a/src/Rooms.js b/src/Rooms.ts similarity index 77% rename from src/Rooms.js rename to src/Rooms.ts index 955498faaa..6e2fd4d3a2 100644 --- a/src/Rooms.js +++ b/src/Rooms.ts @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +Copyright 2015 - 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. @@ -14,7 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from './MatrixClientPeg'; +import { Room } from "matrix-js-sdk/src/models/room"; + +import { MatrixClientPeg } from './MatrixClientPeg'; +import AliasCustomisations from './customisations/Alias'; /** * Given a room object, return the alias we should use for it, @@ -25,11 +28,22 @@ import {MatrixClientPeg} from './MatrixClientPeg'; * @param {Object} room The room object * @returns {string} A display alias for the given room */ -export function getDisplayAliasForRoom(room) { - return room.getCanonicalAlias() || room.getAltAliases()[0]; +export function getDisplayAliasForRoom(room: Room): string { + return getDisplayAliasForAliasSet( + room.getCanonicalAlias(), room.getAltAliases(), + ); } -export function looksLikeDirectMessageRoom(room, myUserId) { +// The various display alias getters should all feed through this one path so +// there's a single place to change the logic. +export function getDisplayAliasForAliasSet(canonicalAlias: string, altAliases: string[]): string { + if (AliasCustomisations.getDisplayAliasForAliasSet) { + return AliasCustomisations.getDisplayAliasForAliasSet(canonicalAlias, altAliases); + } + return canonicalAlias || altAliases?.[0]; +} + +export function looksLikeDirectMessageRoom(room: Room, myUserId: string): boolean { const myMembership = room.getMyMembership(); const me = room.getMember(myUserId); @@ -48,7 +62,7 @@ export function looksLikeDirectMessageRoom(room, myUserId) { return false; } -export function guessAndSetDMRoom(room, isDirect) { +export function guessAndSetDMRoom(room: Room, isDirect: boolean): Promise { let newTarget; if (isDirect) { const guessedUserId = guessDMRoomTargetId( @@ -70,10 +84,8 @@ export function guessAndSetDMRoom(room, isDirect) { this room as a DM room * @returns {object} A promise */ -export function setDMRoom(roomId, userId) { - if (MatrixClientPeg.get().isGuest()) { - return Promise.resolve(); - } +export async function setDMRoom(roomId: string, userId: string): Promise { + if (MatrixClientPeg.get().isGuest()) return; const mDirectEvent = MatrixClientPeg.get().getAccountData('m.direct'); let dmRoomMap = {}; @@ -102,8 +114,7 @@ export function setDMRoom(roomId, userId) { dmRoomMap[userId] = roomList; } - - return MatrixClientPeg.get().setAccountData('m.direct', dmRoomMap); + await MatrixClientPeg.get().setAccountData('m.direct', dmRoomMap); } /** @@ -114,7 +125,7 @@ export function setDMRoom(roomId, userId) { * @param {string} myUserId User ID of the current user * @returns {string} User ID of the user that the room is probably a DM with */ -function guessDMRoomTargetId(room, myUserId) { +function guessDMRoomTargetId(room: Room, myUserId: string): string { let oldestTs; let oldestUser; diff --git a/src/ScalarAuthClient.ts b/src/ScalarAuthClient.ts index a09c3494a8..e791633c8e 100644 --- a/src/ScalarAuthClient.ts +++ b/src/ScalarAuthClient.ts @@ -17,14 +17,16 @@ limitations under the License. import url from 'url'; import SettingsStore from "./settings/SettingsStore"; import { Service, startTermsFlow, TermsInteractionCallback, TermsNotSignedError } from './Terms'; -import {MatrixClientPeg} from "./MatrixClientPeg"; +import { MatrixClientPeg } from "./MatrixClientPeg"; import request from "browser-request"; import SdkConfig from "./SdkConfig"; -import {WidgetType} from "./widgets/WidgetType"; -import {SERVICE_TYPES} from "matrix-js-sdk/src/service-types"; +import { WidgetType } from "./widgets/WidgetType"; +import { SERVICE_TYPES } from "matrix-js-sdk/src/service-types"; import { Room } from "matrix-js-sdk/src/models/room"; +import { logger } from "matrix-js-sdk/src/logger"; + // The version of the integration manager API we're intending to work with const imApiVersion = "1.1"; @@ -109,7 +111,7 @@ export default class ScalarAuthClient { request({ method: "GET", uri: url, - qs: {scalar_token: token, v: imApiVersion}, + qs: { scalar_token: token, v: imApiVersion }, json: true, }, (err, response, body) => { if (err) { @@ -136,7 +138,7 @@ export default class ScalarAuthClient { return token; }).catch((e) => { if (e instanceof TermsNotSignedError) { - console.log("Integration manager requires new terms to be agreed to"); + logger.log("Integration manager requires new terms to be agreed to"); // The terms endpoints are new and so live on standard _matrix prefixes, // but IM rest urls are currently configured with paths, so remove the // path from the base URL before passing it to the js-sdk @@ -189,7 +191,7 @@ export default class ScalarAuthClient { request({ method: 'POST', uri: scalarRestUrl + '/register', - qs: {v: imApiVersion}, + qs: { v: imApiVersion }, body: openidTokenObject, json: true, }, (err, response, body) => { diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.ts similarity index 83% rename from src/ScalarMessaging.js rename to src/ScalarMessaging.ts index 3f75b3788c..888b9ce9ed 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.ts @@ -208,7 +208,6 @@ Example: ] } - membership_state AND bot_options -------------------------------- Get the content of the "m.room.member" or "m.room.bot.options" state event respectively. @@ -236,23 +235,43 @@ Example: } */ -import {MatrixClientPeg} from './MatrixClientPeg'; +import { MatrixClientPeg } from './MatrixClientPeg'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import dis from './dispatcher/dispatcher'; import WidgetUtils from './utils/WidgetUtils'; import RoomViewStore from './stores/RoomViewStore'; import { _t } from './languageHandler'; -import {IntegrationManagers} from "./integrations/IntegrationManagers"; -import {WidgetType} from "./widgets/WidgetType"; -import {objectClone} from "./utils/objects"; +import { IntegrationManagers } from "./integrations/IntegrationManagers"; +import { WidgetType } from "./widgets/WidgetType"; +import { objectClone } from "./utils/objects"; -function sendResponse(event, res) { +import { logger } from "matrix-js-sdk/src/logger"; + +enum Action { + CloseScalar = "close_scalar", + GetWidgets = "get_widgets", + SetWidgets = "set_widgets", + SetWidget = "set_widget", + JoinRulesState = "join_rules_state", + SetPlumbingState = "set_plumbing_state", + GetMembershipCount = "get_membership_count", + GetRoomEncryptionState = "get_room_enc_state", + CanSendEvent = "can_send_event", + MembershipState = "membership_state", + invite = "invite", + BotOptions = "bot_options", + SetBotOptions = "set_bot_options", + SetBotPower = "set_bot_power", +} + +function sendResponse(event: MessageEvent, res: any): void { const data = objectClone(event.data); data.response = res; + // @ts-ignore event.source.postMessage(data, event.origin); } -function sendError(event, msg, nestedError) { +function sendError(event: MessageEvent, msg: string, nestedError?: Error): void { console.error("Action:" + event.data.action + " failed with message: " + msg); const data = objectClone(event.data); data.response = { @@ -263,11 +282,12 @@ function sendError(event, msg, nestedError) { if (nestedError) { data.response.error._error = nestedError; } + // @ts-ignore event.source.postMessage(data, event.origin); } -function inviteUser(event, roomId, userId) { - console.log(`Received request to invite ${userId} into room ${roomId}`); +function inviteUser(event: MessageEvent, roomId: string, userId: string): void { + logger.log(`Received request to invite ${userId} into room ${roomId}`); const client = MatrixClientPeg.get(); if (!client) { sendError(event, _t('You need to be logged in.')); @@ -294,7 +314,7 @@ function inviteUser(event, roomId, userId) { }); } -function setWidget(event, roomId) { +function setWidget(event: MessageEvent, roomId: string): void { const widgetId = event.data.widget_id; let widgetType = event.data.type; const widgetUrl = event.data.url; @@ -355,7 +375,7 @@ function setWidget(event, roomId) { } } -function getWidgets(event, roomId) { +function getWidgets(event: MessageEvent, roomId: string): void { const client = MatrixClientPeg.get(); if (!client) { sendError(event, _t('You need to be logged in.')); @@ -381,7 +401,7 @@ function getWidgets(event, roomId) { sendResponse(event, widgetStateEvents); } -function getRoomEncState(event, roomId) { +function getRoomEncState(event: MessageEvent, roomId: string): void { const client = MatrixClientPeg.get(); if (!client) { sendError(event, _t('You need to be logged in.')); @@ -397,11 +417,11 @@ function getRoomEncState(event, roomId) { sendResponse(event, roomIsEncrypted); } -function setPlumbingState(event, roomId, status) { +function setPlumbingState(event: MessageEvent, roomId: string, status: string): void { if (typeof status !== 'string') { throw new Error('Plumbing state status should be a string'); } - console.log(`Received request to set plumbing state to status "${status}" in room ${roomId}`); + logger.log(`Received request to set plumbing state to status "${status}" in room ${roomId}`); const client = MatrixClientPeg.get(); if (!client) { sendError(event, _t('You need to be logged in.')); @@ -416,8 +436,8 @@ function setPlumbingState(event, roomId, status) { }); } -function setBotOptions(event, roomId, userId) { - console.log(`Received request to set options for bot ${userId} in room ${roomId}`); +function setBotOptions(event: MessageEvent, roomId: string, userId: string): void { + logger.log(`Received request to set options for bot ${userId} in room ${roomId}`); const client = MatrixClientPeg.get(); if (!client) { sendError(event, _t('You need to be logged in.')); @@ -432,13 +452,13 @@ function setBotOptions(event, roomId, userId) { }); } -function setBotPower(event, roomId, userId, level) { +function setBotPower(event: MessageEvent, roomId: string, userId: string, level: number): void { if (!(Number.isInteger(level) && level >= 0)) { sendError(event, _t('Power level must be positive integer.')); return; } - console.log(`Received request to set power level to ${level} for bot ${userId} in room ${roomId}.`); + logger.log(`Received request to set power level to ${level} for bot ${userId} in room ${roomId}.`); const client = MatrixClientPeg.get(); if (!client) { sendError(event, _t('You need to be logged in.')); @@ -463,22 +483,22 @@ function setBotPower(event, roomId, userId, level) { }); } -function getMembershipState(event, roomId, userId) { - console.log(`membership_state of ${userId} in room ${roomId} requested.`); +function getMembershipState(event: MessageEvent, roomId: string, userId: string): void { + logger.log(`membership_state of ${userId} in room ${roomId} requested.`); returnStateEvent(event, roomId, "m.room.member", userId); } -function getJoinRules(event, roomId) { - console.log(`join_rules of ${roomId} requested.`); +function getJoinRules(event: MessageEvent, roomId: string): void { + logger.log(`join_rules of ${roomId} requested.`); returnStateEvent(event, roomId, "m.room.join_rules", ""); } -function botOptions(event, roomId, userId) { - console.log(`bot_options of ${userId} in room ${roomId} requested.`); +function botOptions(event: MessageEvent, roomId: string, userId: string): void { + logger.log(`bot_options of ${userId} in room ${roomId} requested.`); returnStateEvent(event, roomId, "m.room.bot.options", "_" + userId); } -function getMembershipCount(event, roomId) { +function getMembershipCount(event: MessageEvent, roomId: string): void { const client = MatrixClientPeg.get(); if (!client) { sendError(event, _t('You need to be logged in.')); @@ -493,7 +513,7 @@ function getMembershipCount(event, roomId) { sendResponse(event, count); } -function canSendEvent(event, roomId) { +function canSendEvent(event: MessageEvent, roomId: string): void { const evType = "" + event.data.event_type; // force stringify const isState = Boolean(event.data.is_state); const client = MatrixClientPeg.get(); @@ -527,7 +547,7 @@ function canSendEvent(event, roomId) { sendResponse(event, true); } -function returnStateEvent(event, roomId, eventType, stateKey) { +function returnStateEvent(event: MessageEvent, roomId: string, eventType: string, stateKey: string): void { const client = MatrixClientPeg.get(); if (!client) { sendError(event, _t('You need to be logged in.')); @@ -546,8 +566,9 @@ function returnStateEvent(event, roomId, eventType, stateKey) { sendResponse(event, stateEvent.getContent()); } -const onMessage = function(event) { +const onMessage = function(event: MessageEvent): void { if (!event.origin) { // stupid chrome + // @ts-ignore event.origin = event.originalEvent.origin; } @@ -581,8 +602,8 @@ const onMessage = function(event) { return; } - if (event.data.action === "close_scalar") { - dis.dispatch({ action: "close_scalar" }); + if (event.data.action === Action.CloseScalar) { + dis.dispatch({ action: Action.CloseScalar }); sendResponse(event, null); return; } @@ -595,10 +616,10 @@ const onMessage = function(event) { // Get and set user widgets (not associated with a specific room) // If roomId is specified, it must be validated, so room-based widgets agreed // handled further down. - if (event.data.action === "get_widgets") { + if (event.data.action === Action.GetWidgets) { getWidgets(event, null); return; - } else if (event.data.action === "set_widget") { + } else if (event.data.action === Action.SetWidgets) { setWidget(event, null); return; } else { @@ -608,33 +629,33 @@ const onMessage = function(event) { } if (roomId !== RoomViewStore.getRoomId()) { - sendError(event, _t('Room %(roomId)s not visible', {roomId: roomId})); + sendError(event, _t('Room %(roomId)s not visible', { roomId: roomId })); return; } // Get and set room-based widgets - if (event.data.action === "get_widgets") { + if (event.data.action === Action.GetWidgets) { getWidgets(event, roomId); return; - } else if (event.data.action === "set_widget") { + } else if (event.data.action === Action.SetWidget) { setWidget(event, roomId); return; } // These APIs don't require userId - if (event.data.action === "join_rules_state") { + if (event.data.action === Action.JoinRulesState) { getJoinRules(event, roomId); return; - } else if (event.data.action === "set_plumbing_state") { + } else if (event.data.action === Action.SetPlumbingState) { setPlumbingState(event, roomId, event.data.status); return; - } else if (event.data.action === "get_membership_count") { + } else if (event.data.action === Action.GetMembershipCount) { getMembershipCount(event, roomId); return; - } else if (event.data.action === "get_room_enc_state") { + } else if (event.data.action === Action.GetRoomEncryptionState) { getRoomEncState(event, roomId); return; - } else if (event.data.action === "can_send_event") { + } else if (event.data.action === Action.CanSendEvent) { canSendEvent(event, roomId); return; } @@ -644,19 +665,19 @@ const onMessage = function(event) { return; } switch (event.data.action) { - case "membership_state": + case Action.MembershipState: getMembershipState(event, roomId, userId); break; - case "invite": + case Action.invite: inviteUser(event, roomId, userId); break; - case "bot_options": + case Action.BotOptions: botOptions(event, roomId, userId); break; - case "set_bot_options": + case Action.SetBotOptions: setBotOptions(event, roomId, userId); break; - case "set_bot_power": + case Action.SetBotPower: setBotPower(event, roomId, userId, event.data.level); break; default: @@ -666,16 +687,16 @@ const onMessage = function(event) { }; let listenerCount = 0; -let openManagerUrl = null; +let openManagerUrl: string = null; -export function startListening() { +export function startListening(): void { if (listenerCount === 0) { window.addEventListener("message", onMessage, false); } listenerCount += 1; } -export function stopListening() { +export function stopListening(): void { listenerCount -= 1; if (listenerCount === 0) { window.removeEventListener("message", onMessage); @@ -690,6 +711,6 @@ export function stopListening() { } } -export function setOpenManagerUrl(url) { +export function setOpenManagerUrl(url: string): void { openManagerUrl = url; } diff --git a/src/Searching.js b/src/Searching.ts similarity index 78% rename from src/Searching.js rename to src/Searching.ts index 2b17aee054..37f85efa77 100644 --- a/src/Searching.js +++ b/src/Searching.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019 - 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. @@ -14,26 +14,42 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { + IResultRoomEvents, + ISearchRequestBody, + ISearchResponse, + ISearchResult, + ISearchResults, + SearchOrderBy, +} from "matrix-js-sdk/src/@types/search"; +import { IRoomEventFilter } from "matrix-js-sdk/src/filter"; +import { EventType } from "matrix-js-sdk/src/@types/event"; + +import { ISearchArgs } from "./indexing/BaseEventIndexManager"; import EventIndexPeg from "./indexing/EventIndexPeg"; -import {MatrixClientPeg} from "./MatrixClientPeg"; +import { MatrixClientPeg } from "./MatrixClientPeg"; +import { SearchResult } from "matrix-js-sdk/src/models/search-result"; const SEARCH_LIMIT = 10; -async function serverSideSearch(term, roomId = undefined) { +async function serverSideSearch( + term: string, + roomId: string = undefined, +): Promise<{ response: ISearchResponse, query: ISearchRequestBody }> { const client = MatrixClientPeg.get(); - const filter = { + const filter: IRoomEventFilter = { limit: SEARCH_LIMIT, }; if (roomId !== undefined) filter.rooms = [roomId]; - const body = { + const body: ISearchRequestBody = { search_categories: { room_events: { search_term: term, filter: filter, - order_by: "recent", + order_by: SearchOrderBy.Recent, event_context: { before_limit: 1, after_limit: 1, @@ -43,33 +59,28 @@ async function serverSideSearch(term, roomId = undefined) { }, }; - const response = await client.search({body: body}); + const response = await client.search({ body: body }); - const result = { - response: response, - query: body, - }; - - return result; + return { response, query: body }; } -async function serverSideSearchProcess(term, roomId = undefined) { +async function serverSideSearchProcess(term: string, roomId: string = undefined): Promise { const client = MatrixClientPeg.get(); const result = await serverSideSearch(term, roomId); // The js-sdk method backPaginateRoomEventsSearch() uses _query internally - // so we're reusing the concept here since we wan't to delegate the + // so we're reusing the concept here since we want to delegate the // pagination back to backPaginateRoomEventsSearch() in some cases. - const searchResult = { + const searchResults: ISearchResults = { _query: result.query, results: [], highlights: [], }; - return client.processRoomEventsSearch(searchResult, result.response); + return client.processRoomEventsSearch(searchResults, result.response); } -function compareEvents(a, b) { +function compareEvents(a: ISearchResult, b: ISearchResult): number { const aEvent = a.result; const bEvent = b.result; @@ -79,7 +90,7 @@ function compareEvents(a, b) { return 0; } -async function combinedSearch(searchTerm) { +async function combinedSearch(searchTerm: string): Promise { const client = MatrixClientPeg.get(); // Create two promises, one for the local search, one for the @@ -111,10 +122,10 @@ async function combinedSearch(searchTerm) { // returns since that one can be either a server-side one, a local one or a // fake one to fetch the remaining cached events. See the docs for // combineEvents() for an explanation why we need to cache events. - const emptyResult = { + const emptyResult: ISeshatSearchResults = { seshatQuery: localQuery, _query: serverQuery, - serverSideNextBatch: serverResponse.next_batch, + serverSideNextBatch: serverResponse.search_categories.room_events.next_batch, cachedEvents: [], oldestEventFrom: "server", results: [], @@ -125,7 +136,7 @@ async function combinedSearch(searchTerm) { const combinedResult = combineResponses(emptyResult, localResponse, serverResponse.search_categories.room_events); // Let the client process the combined result. - const response = { + const response: ISearchResponse = { search_categories: { room_events: combinedResult, }, @@ -139,10 +150,14 @@ async function combinedSearch(searchTerm) { return result; } -async function localSearch(searchTerm, roomId = undefined, processResult = true) { +async function localSearch( + searchTerm: string, + roomId: string = undefined, + processResult = true, +): Promise<{ response: IResultRoomEvents, query: ISearchArgs }> { const eventIndex = EventIndexPeg.get(); - const searchArgs = { + const searchArgs: ISearchArgs = { search_term: searchTerm, before_limit: 1, after_limit: 1, @@ -167,11 +182,18 @@ async function localSearch(searchTerm, roomId = undefined, processResult = true) return result; } -async function localSearchProcess(searchTerm, roomId = undefined) { +export interface ISeshatSearchResults extends ISearchResults { + seshatQuery?: ISearchArgs; + cachedEvents?: ISearchResult[]; + oldestEventFrom?: "local" | "server"; + serverSideNextBatch?: string; +} + +async function localSearchProcess(searchTerm: string, roomId: string = undefined): Promise { const emptyResult = { results: [], highlights: [], - }; + } as ISeshatSearchResults; if (searchTerm === "") return emptyResult; @@ -179,7 +201,7 @@ async function localSearchProcess(searchTerm, roomId = undefined) { emptyResult.seshatQuery = result.query; - const response = { + const response: ISearchResponse = { search_categories: { room_events: result.response, }, @@ -192,7 +214,7 @@ async function localSearchProcess(searchTerm, roomId = undefined) { return processedResult; } -async function localPagination(searchResult) { +async function localPagination(searchResult: ISeshatSearchResults): Promise { const eventIndex = EventIndexPeg.get(); const searchArgs = searchResult.seshatQuery; @@ -221,10 +243,10 @@ async function localPagination(searchResult) { return result; } -function compareOldestEvents(firstResults, secondResults) { +function compareOldestEvents(firstResults: ISearchResult[], secondResults: ISearchResult[]): number { try { - const oldestFirstEvent = firstResults.results[firstResults.results.length - 1].result; - const oldestSecondEvent = secondResults.results[secondResults.results.length - 1].result; + const oldestFirstEvent = firstResults[firstResults.length - 1].result; + const oldestSecondEvent = secondResults[secondResults.length - 1].result; if (oldestFirstEvent.origin_server_ts <= oldestSecondEvent.origin_server_ts) { return -1; @@ -236,7 +258,12 @@ function compareOldestEvents(firstResults, secondResults) { } } -function combineEventSources(previousSearchResult, response, a, b) { +function combineEventSources( + previousSearchResult: ISeshatSearchResults, + response: IResultRoomEvents, + a: ISearchResult[], + b: ISearchResult[], +): void { // Merge event sources and sort the events. const combinedEvents = a.concat(b).sort(compareEvents); // Put half of the events in the response, and cache the other half. @@ -353,8 +380,12 @@ function combineEventSources(previousSearchResult, response, a, b) { * different event sources. * */ -function combineEvents(previousSearchResult, localEvents = undefined, serverEvents = undefined) { - const response = {}; +function combineEvents( + previousSearchResult: ISeshatSearchResults, + localEvents: IResultRoomEvents = undefined, + serverEvents: IResultRoomEvents = undefined, +): IResultRoomEvents { + const response = {} as IResultRoomEvents; const cachedEvents = previousSearchResult.cachedEvents; let oldestEventFrom = previousSearchResult.oldestEventFrom; @@ -364,7 +395,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven // 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, serverEvents) < 0) { + if (compareOldestEvents(localEvents.results, serverEvents.results) < 0) { oldestEventFrom = "local"; } @@ -375,7 +406,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven // 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, cachedEvents) < 0) { + if (compareOldestEvents(localEvents.results, cachedEvents) < 0) { oldestEventFrom = "local"; } combineEventSources(previousSearchResult, response, localEvents.results, cachedEvents); @@ -384,7 +415,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven // meaning that our oldest event was in the local index. // Change the source of the oldest event if our server event is older // than the cached one. - if (compareOldestEvents(serverEvents, cachedEvents) < 0) { + if (compareOldestEvents(serverEvents.results, cachedEvents) < 0) { oldestEventFrom = "server"; } combineEventSources(previousSearchResult, response, serverEvents.results, cachedEvents); @@ -412,7 +443,11 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven * @return {object} A response object that combines the events from the * different event sources. */ -function combineResponses(previousSearchResult, localEvents = undefined, serverEvents = undefined) { +function combineResponses( + previousSearchResult: ISeshatSearchResults, + localEvents: IResultRoomEvents = undefined, + serverEvents: IResultRoomEvents = undefined, +): IResultRoomEvents { // Combine our events first. const response = combineEvents(previousSearchResult, localEvents, serverEvents); @@ -454,42 +489,51 @@ function combineResponses(previousSearchResult, localEvents = undefined, serverE return response; } -function restoreEncryptionInfo(searchResultSlice = []) { +interface IEncryptedSeshatEvent { + curve25519Key: string; + ed25519Key: string; + algorithm: string; + forwardingCurve25519KeyChain: string[]; +} + +function restoreEncryptionInfo(searchResultSlice: SearchResult[] = []): void { for (let i = 0; i < searchResultSlice.length; i++) { const timeline = searchResultSlice[i].context.getTimeline(); for (let j = 0; j < timeline.length; j++) { - const ev = timeline[j]; + const mxEv = timeline[j]; + const ev = mxEv.event as IEncryptedSeshatEvent; - if (ev.event.curve25519Key) { - ev.makeEncrypted( - "m.room.encrypted", - { algorithm: ev.event.algorithm }, - ev.event.curve25519Key, - ev.event.ed25519Key, + if (ev.curve25519Key) { + mxEv.makeEncrypted( + EventType.RoomMessageEncrypted, + { algorithm: ev.algorithm }, + ev.curve25519Key, + ev.ed25519Key, ); - ev._forwardingCurve25519KeyChain = ev.event.forwardingCurve25519KeyChain; + // @ts-ignore + mxEv.forwardingCurve25519KeyChain = ev.forwardingCurve25519KeyChain; - delete ev.event.curve25519Key; - delete ev.event.ed25519Key; - delete ev.event.algorithm; - delete ev.event.forwardingCurve25519KeyChain; + delete ev.curve25519Key; + delete ev.ed25519Key; + delete ev.algorithm; + delete ev.forwardingCurve25519KeyChain; } } } } -async function combinedPagination(searchResult) { +async function combinedPagination(searchResult: ISeshatSearchResults): Promise { const eventIndex = EventIndexPeg.get(); const client = MatrixClientPeg.get(); const searchArgs = searchResult.seshatQuery; const oldestEventFrom = searchResult.oldestEventFrom; - let localResult; - let serverSideResult; + let localResult: IResultRoomEvents; + let serverSideResult: ISearchResponse; - // Fetch events from the local index if we have a token for itand if it's + // Fetch events from the local index if we have a token for it and if it's // the local indexes turn or the server has exhausted its results. if (searchArgs.next_batch && (!searchResult.serverSideNextBatch || oldestEventFrom === "server")) { localResult = await eventIndex.search(searchArgs); @@ -498,11 +542,11 @@ async function combinedPagination(searchResult) { // Fetch events from the server if we have a token for it and if it's the // local indexes turn or the local index has exhausted its results. if (searchResult.serverSideNextBatch && (oldestEventFrom === "local" || !searchArgs.next_batch)) { - const body = {body: searchResult._query, next_batch: searchResult.serverSideNextBatch}; + const body = { body: searchResult._query, next_batch: searchResult.serverSideNextBatch }; serverSideResult = await client.search(body); } - let serverEvents; + let serverEvents: IResultRoomEvents; if (serverSideResult) { serverEvents = serverSideResult.search_categories.room_events; @@ -532,8 +576,8 @@ async function combinedPagination(searchResult) { return result; } -function eventIndexSearch(term, roomId = undefined) { - let searchPromise; +function eventIndexSearch(term: string, roomId: string = undefined): Promise { + let searchPromise: Promise; if (roomId !== undefined) { if (MatrixClientPeg.get().isRoomEncrypted(roomId)) { @@ -554,7 +598,7 @@ function eventIndexSearch(term, roomId = undefined) { return searchPromise; } -function eventIndexSearchPagination(searchResult) { +function eventIndexSearchPagination(searchResult: ISeshatSearchResults): Promise { const client = MatrixClientPeg.get(); const seshatQuery = searchResult.seshatQuery; @@ -580,7 +624,7 @@ function eventIndexSearchPagination(searchResult) { } } -export function searchPagination(searchResult) { +export function searchPagination(searchResult: ISearchResults): Promise { const eventIndex = EventIndexPeg.get(); const client = MatrixClientPeg.get(); @@ -590,7 +634,7 @@ export function searchPagination(searchResult) { else return eventIndexSearchPagination(searchResult); } -export default function eventSearch(term, roomId = undefined) { +export default function eventSearch(term: string, roomId: string = undefined): Promise { const eventIndex = EventIndexPeg.get(); if (eventIndex === null) return serverSideSearchProcess(term, roomId); diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index 09c8d30614..925b023584 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -14,11 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ICryptoCallbacks, IDeviceTrustLevel, ISecretStorageKeyInfo } from 'matrix-js-sdk/src/matrix'; +import { ICryptoCallbacks } from 'matrix-js-sdk/src/matrix'; +import { ISecretStorageKeyInfo } from 'matrix-js-sdk/src/crypto/api'; import { MatrixClient } from 'matrix-js-sdk/src/client'; import Modal from './Modal'; import * as sdk from './index'; -import {MatrixClientPeg} from './MatrixClientPeg'; +import { MatrixClientPeg } from './MatrixClientPeg'; import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase'; import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey'; import { _t } from './languageHandler'; @@ -28,6 +29,9 @@ import AccessSecretStorageDialog from './components/views/dialogs/security/Acces import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog'; import SettingsStore from "./settings/SettingsStore"; import SecurityCustomisations from "./customisations/Security"; +import { DeviceTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning'; + +import { logger } from "matrix-js-sdk/src/logger"; // This stores the secret storage private keys in memory for the JS SDK. This is // only meant to act as a cache to avoid prompting the user multiple times @@ -41,8 +45,8 @@ let secretStorageBeingAccessed = false; let nonInteractive = false; let dehydrationCache: { - key?: Uint8Array, - keyInfo?: ISecretStorageKeyInfo, + key?: Uint8Array; + keyInfo?: ISecretStorageKeyInfo; } = {}; function isCachingAllowed(): boolean { @@ -134,7 +138,7 @@ async function getSecretStorageKey( const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.(); if (keyFromCustomisations) { - console.log("Using key from security customisations (secret storage)") + logger.log("Using key from security customisations (secret storage)"); cacheSecretStorageKey(keyId, keyInfo, keyFromCustomisations); return [keyId, keyFromCustomisations]; } @@ -184,7 +188,7 @@ export async function getDehydrationKey( ): Promise { const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.(); if (keyFromCustomisations) { - console.log("Using key from security customisations (dehydration)") + logger.log("Using key from security customisations (dehydration)"); return keyFromCustomisations; } @@ -223,7 +227,7 @@ export async function getDehydrationKey( const key = await inputToKey(input); // need to copy the key because rehydration (unpickling) will clobber it - dehydrationCache = {key: new Uint8Array(key), keyInfo}; + dehydrationCache = { key: new Uint8Array(key), keyInfo }; return key; } @@ -244,15 +248,15 @@ async function onSecretRequested( deviceId: string, requestId: string, name: string, - deviceTrust: IDeviceTrustLevel, + deviceTrust: DeviceTrustLevel, ): Promise { - console.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust); + logger.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust); const client = MatrixClientPeg.get(); if (userId !== client.getUserId()) { return; } if (!deviceTrust || !deviceTrust.isVerified()) { - console.log(`Ignoring secret request from untrusted device ${deviceId}`); + logger.log(`Ignoring secret request from untrusted device ${deviceId}`); return; } if ( @@ -265,7 +269,7 @@ async function onSecretRequested( const keyId = name.replace("m.cross_signing.", ""); const key = await callbacks.getCrossSigningKeyCache(keyId); if (!key) { - console.log( + logger.log( `${keyId} requested by ${deviceId}, but not found in cache`, ); } @@ -273,7 +277,7 @@ async function onSecretRequested( } else if (name === "m.megolm_backup.v1") { const key = await client.crypto.getSessionBackupPrivateKey(); if (!key) { - console.log( + logger.log( `session backup key requested by ${deviceId}, but not found in cache`, ); } @@ -327,7 +331,7 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f const cli = MatrixClientPeg.get(); secretStorageBeingAccessed = true; try { - if (!await cli.hasSecretStorageKey() || forceReset) { + if (!(await cli.hasSecretStorageKey()) || forceReset) { // This dialog calls bootstrap itself after guiding the user through // passphrase creation. const { finished } = Modal.createTrackedDialogAsync('Create Secret Storage dialog', '', @@ -353,6 +357,7 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f throw new Error("Secret storage creation canceled"); } } else { + // FIXME: Using an import will result in test failures const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); await cli.bootstrapCrossSigning({ authUploadDeviceSigningKeys: async (makeRequest) => { @@ -380,12 +385,12 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f if (secretStorageKeyInfo[keyId] && secretStorageKeyInfo[keyId].passphrase) { dehydrationKeyInfo = { passphrase: secretStorageKeyInfo[keyId].passphrase }; } - console.log("Setting dehydration key"); + logger.log("Setting dehydration key"); await cli.setDehydrationKey(secretStorageKeys[keyId], dehydrationKeyInfo, "Backup device"); } else if (!keyId) { console.warn("Not setting dehydration key: no SSSS key found"); } else { - console.log("Not setting dehydration key: feature disabled"); + logger.log("Not setting dehydration key: feature disabled"); } } @@ -413,8 +418,8 @@ export async function tryToUnlockSecretStorageWithDehydrationKey( ): Promise { const key = dehydrationCache.key; let restoringBackup = false; - if (key && await client.isSecretStorageReady()) { - console.log("Trying to set up cross-signing using dehydration key"); + if (key && (await client.isSecretStorageReady())) { + logger.log("Trying to set up cross-signing using dehydration key"); secretStorageBeingAccessed = true; nonInteractive = true; try { diff --git a/src/SendHistoryManager.ts b/src/SendHistoryManager.ts index e9268ad642..eeba643d81 100644 --- a/src/SendHistoryManager.ts +++ b/src/SendHistoryManager.ts @@ -1,4 +1,3 @@ -//@flow /* Copyright 2017 Aviral Dasgupta @@ -15,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {clamp} from "lodash"; -import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import { clamp } from "lodash"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import {SerializedPart} from "./editor/parts"; +import { SerializedPart } from "./editor/parts"; import EditorModel from "./editor/model"; interface IHistoryItem { diff --git a/src/Skinner.js b/src/Skinner.ts similarity index 84% rename from src/Skinner.js rename to src/Skinner.ts index ef340e4052..6b20781b59 100644 --- a/src/Skinner.js +++ b/src/Skinner.ts @@ -14,12 +14,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -class Skinner { - constructor() { - this.components = null; - } +import React from "react"; - getComponent(name) { +export interface IComponents { + [key: string]: React.Component; +} + +export interface ISkinObject { + components: IComponents; +} + +export class Skinner { + public components: IComponents = null; + + public getComponent(name: string): React.Component { if (!name) throw new Error(`Invalid component name: ${name}`); if (this.components === null) { throw new Error( @@ -30,7 +38,7 @@ class Skinner { ); } - const doLookup = (components) => { + const doLookup = (components: IComponents): React.Component => { if (!components) return null; let comp = components[name]; // XXX: Temporarily also try 'views.' as we're currently @@ -58,7 +66,7 @@ class Skinner { return comp; } - load(skinObject) { + public load(skinObject: ISkinObject): void { if (this.components !== null) { throw new Error( "Attempted to load a skin while a skin is already loaded"+ @@ -72,6 +80,7 @@ class Skinner { } // Now that we have a skin, load our components too + // eslint-disable-next-line @typescript-eslint/no-var-requires const idx = require("./component-index"); if (!idx || !idx.components) throw new Error("Invalid react-sdk component index"); for (const c in idx.components) { @@ -79,7 +88,7 @@ class Skinner { } } - addComponent(name, comp) { + public addComponent(name: string, comp: any) { let slot = name; if (comp.replaces !== undefined) { if (comp.replaces.indexOf('.') > -1) { @@ -91,7 +100,7 @@ class Skinner { this.components[slot] = comp; } - reset() { + public reset(): void { this.components = null; } } @@ -105,8 +114,8 @@ class Skinner { // See https://derickbailey.com/2016/03/09/creating-a-true-singleton-in-node-js-with-es6-symbols/ // or https://nodejs.org/api/modules.html#modules_module_caching_caveats // ("Modules are cached based on their resolved filename") -if (global.mxSkinner === undefined) { - global.mxSkinner = new Skinner(); +if (window.mxSkinner === undefined) { + window.mxSkinner = new Skinner(); } -export default global.mxSkinner; +export default window.mxSkinner; diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 4a7b37b5e5..f3214c4757 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -17,25 +17,23 @@ See the License for the specific language governing permissions and limitations under the License. */ - import * as React from 'react'; +import { User } from "matrix-js-sdk/src/models/user"; import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers'; -import {MatrixClientPeg} from './MatrixClientPeg'; +import { MatrixClientPeg } from './MatrixClientPeg'; import dis from './dispatcher/dispatcher'; -import * as sdk from './index'; -import {_t, _td} from './languageHandler'; +import { _t, _td } from './languageHandler'; import Modal from './Modal'; import MultiInviter from './utils/MultiInviter'; import { linkifyAndSanitizeHtml } from './HtmlUtils'; import QuestionDialog from "./components/views/dialogs/QuestionDialog"; import WidgetUtils from "./utils/WidgetUtils"; -import {textToHtmlRainbow} from "./utils/colour"; +import { textToHtmlRainbow } from "./utils/colour"; import { getAddressType } from './UserAddress'; import { abbreviateUrl } from './utils/UrlUtils'; import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from './utils/IdentityServerUtils'; -import {isPermalinkHost, parsePermalink} from "./utils/permalinks/Permalinks"; -import {inviteUsersToRoom} from "./RoomInvite"; +import { isPermalinkHost, parsePermalink } from "./utils/permalinks/Permalinks"; import { WidgetType } from "./widgets/WidgetType"; import { Jitsi } from "./widgets/Jitsi"; import { parseFragment as parseHtml, Element as ChildElement } from "parse5"; @@ -46,10 +44,18 @@ import { Action } from "./dispatcher/actions"; import { EffectiveMembership, getEffectiveMembership, leaveRoomBehaviour } from "./utils/membership"; import SdkConfig from "./SdkConfig"; import SettingsStore from "./settings/SettingsStore"; -import {UIFeature} from "./settings/UIFeature"; -import {CHAT_EFFECTS} from "./effects" +import { UIFeature } from "./settings/UIFeature"; +import { CHAT_EFFECTS } from "./effects"; import CallHandler from "./CallHandler"; -import {guessAndSetDMRoom} from "./Rooms"; +import { guessAndSetDMRoom } from "./Rooms"; +import { upgradeRoom } from './utils/RoomUpgrade'; +import UploadConfirmDialog from './components/views/dialogs/UploadConfirmDialog'; +import DevtoolsDialog from './components/views/dialogs/DevtoolsDialog'; +import RoomUpgradeWarningDialog from "./components/views/dialogs/RoomUpgradeWarningDialog"; +import InfoDialog from "./components/views/dialogs/InfoDialog"; +import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpDialog"; + +import { logger } from "matrix-js-sdk/src/logger"; // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 interface HTMLInputEvent extends Event { @@ -63,7 +69,6 @@ const singleMxcUpload = async (): Promise => { fileSelector.onchange = (ev: HTMLInputEvent) => { const file = ev.target.files[0]; - const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog"); Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, { file, onFinished: (shouldContinue) => { @@ -143,11 +148,15 @@ export class Command { } function reject(error) { - return {error}; + return { error }; } function success(promise?: Promise) { - return {promise}; + return { promise }; +} + +function successSync(value: any) { + return success(Promise.resolve(value)); } /* Disable the "unexpected this" error for these commands - all of the run @@ -160,7 +169,7 @@ export const Commands = [ args: '', description: _td('Sends the given message as a spoiler'), runFn: function(roomId, message) { - return success(ContentHelpers.makeHtmlMessage( + return successSync(ContentHelpers.makeHtmlMessage( message, `${message}`, )); @@ -176,7 +185,7 @@ export const Commands = [ if (args) { message = message + ' ' + args; } - return success(ContentHelpers.makeTextMessage(message)); + return successSync(ContentHelpers.makeTextMessage(message)); }, category: CommandCategories.messages, }), @@ -189,7 +198,7 @@ export const Commands = [ if (args) { message = message + ' ' + args; } - return success(ContentHelpers.makeTextMessage(message)); + return successSync(ContentHelpers.makeTextMessage(message)); }, category: CommandCategories.messages, }), @@ -202,7 +211,7 @@ export const Commands = [ if (args) { message = message + ' ' + args; } - return success(ContentHelpers.makeTextMessage(message)); + return successSync(ContentHelpers.makeTextMessage(message)); }, category: CommandCategories.messages, }), @@ -215,7 +224,7 @@ export const Commands = [ if (args) { message = message + ' ' + args; } - return success(ContentHelpers.makeTextMessage(message)); + return successSync(ContentHelpers.makeTextMessage(message)); }, category: CommandCategories.messages, }), @@ -224,7 +233,7 @@ export const Commands = [ args: '', description: _td('Sends a message as plain text, without interpreting it as markdown'), runFn: function(roomId, messages) { - return success(ContentHelpers.makeTextMessage(messages)); + return successSync(ContentHelpers.makeTextMessage(messages)); }, category: CommandCategories.messages, }), @@ -233,26 +242,10 @@ export const Commands = [ args: '', description: _td('Sends a message as html, without interpreting it as markdown'), runFn: function(roomId, messages) { - return success(ContentHelpers.makeHtmlMessage(messages, messages)); + return successSync(ContentHelpers.makeHtmlMessage(messages, messages)); }, category: CommandCategories.messages, }), - new Command({ - command: 'ddg', - args: '', - description: _td('Searches DuckDuckGo for results'), - runFn: function() { - const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); - // TODO Don't explain this away, actually show a search UI here. - Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, { - title: _t('/ddg is not a command'), - description: _t('To use it, just wait for autocomplete results to load and tab through them.'), - }); - return success(); - }, - category: CommandCategories.actions, - hideCompletionAfterSpace: true, - }), new Command({ command: 'upgraderoom', args: '', @@ -265,58 +258,13 @@ export const Commands = [ return reject(_t("You do not have the required permissions to use this command.")); } - const RoomUpgradeWarningDialog = sdk.getComponent("dialogs.RoomUpgradeWarningDialog"); - - const {finished} = Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation', - RoomUpgradeWarningDialog, {roomId: roomId, targetVersion: args}, /*className=*/null, + const { finished } = Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation', + RoomUpgradeWarningDialog, { roomId: roomId, targetVersion: args }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); return success(finished.then(async ([resp]) => { - if (!resp.continue) return; - - let checkForUpgradeFn; - try { - const upgradePromise = cli.upgradeRoom(roomId, args); - - // We have to wait for the js-sdk to give us the room back so - // we can more effectively abuse the MultiInviter behaviour - // which heavily relies on the Room object being available. - if (resp.invite) { - checkForUpgradeFn = async (newRoom) => { - // The upgradePromise should be done by the time we await it here. - const {replacement_room: newRoomId} = await upgradePromise; - if (newRoom.roomId !== newRoomId) return; - - const toInvite = [ - ...room.getMembersWithMembership("join"), - ...room.getMembersWithMembership("invite"), - ].map(m => m.userId).filter(m => m !== cli.getUserId()); - - if (toInvite.length > 0) { - // Errors are handled internally to this function - await inviteUsersToRoom(newRoomId, toInvite); - } - - cli.removeListener('Room', checkForUpgradeFn); - }; - cli.on('Room', checkForUpgradeFn); - } - - // We have to await after so that the checkForUpgradesFn has a proper reference - // to the new room's ID. - await upgradePromise; - } catch (e) { - console.error(e); - - if (checkForUpgradeFn) cli.removeListener('Room', checkForUpgradeFn); - - const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); - Modal.createTrackedDialog('Slash Commands', 'room upgrade error', ErrorDialog, { - title: _t('Error upgrading room'), - description: _t( - 'Double check that your server supports the room version chosen and try again.'), - }); - } + if (!resp?.continue) return; + await upgradeRoom(room, args, resp.invite); })); } return reject(this.getUsage()); @@ -345,7 +293,7 @@ export const Commands = [ const cli = MatrixClientPeg.get(); const ev = cli.getRoom(roomId).currentState.getStateEvents('m.room.member', cli.getUserId()); const content = { - ...ev ? ev.getContent() : { membership: 'join' }, + ...(ev ? ev.getContent() : { membership: 'join' }), displayname: args, }; return success(cli.sendStateEvent(roomId, 'm.room.member', content, cli.getUserId())); @@ -366,7 +314,7 @@ export const Commands = [ return success(promise.then((url) => { if (!url) return; - return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.avatar', {url}, ''); + return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.avatar', { url }, ''); })); }, category: CommandCategories.actions, @@ -389,7 +337,7 @@ export const Commands = [ if (!url) return; const ev = room.currentState.getStateEvents('m.room.member', userId); const content = { - ...ev ? ev.getContent() : { membership: 'join' }, + ...(ev ? ev.getContent() : { membership: 'join' }), avatar_url: url, }; return cli.sendStateEvent(roomId, 'm.room.member', content, userId); @@ -430,7 +378,6 @@ export const Commands = [ const topic = topicEvents && topicEvents.getContent().topic; const topicHtml = topic ? linkifyAndSanitizeHtml(topic) : _t('This room has no topic.'); - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); Modal.createTrackedDialog('Slash Commands', 'Topic', InfoDialog, { title: room.name, description:
, @@ -477,14 +424,14 @@ export const Commands = [ 'Identity server', QuestionDialog, { title: _t("Use an identity server"), - description:

{_t( + description:

{ _t( "Use an identity server to invite by email. " + "Click continue to use the default identity server " + "(%(defaultIdentityServerName)s) or manage in Settings.", { defaultIdentityServerName: abbreviateUrl(defaultIdentityServerUrl), }, - )}

, + ) }

, button: _t("Continue"), }, ); @@ -519,7 +466,7 @@ export const Commands = [ aliases: ['j', 'goto'], args: '', description: _td('Joins room with given address'), - runFn: function(_, args) { + runFn: function(roomId, args) { if (args) { // Note: we support 2 versions of this command. The first is // the public-facing one for most users and the other is a @@ -733,11 +680,10 @@ export const Commands = [ ignoredUsers.push(userId); // de-duped internally in the js-sdk return success( cli.setIgnoredUsers(ignoredUsers).then(() => { - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); Modal.createTrackedDialog('Slash Commands', 'User ignored', InfoDialog, { title: _t('Ignored user'), description:
-

{ _t('You are now ignoring %(userId)s', {userId}) }

+

{ _t('You are now ignoring %(userId)s', { userId }) }

, }); }), @@ -764,11 +710,10 @@ export const Commands = [ if (index !== -1) ignoredUsers.splice(index, 1); return success( cli.setIgnoredUsers(ignoredUsers).then(() => { - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); Modal.createTrackedDialog('Slash Commands', 'User unignored', InfoDialog, { title: _t('Unignored user'), description:
-

{ _t('You are no longer ignoring %(userId)s', {userId}) }

+

{ _t('You are no longer ignoring %(userId)s', { userId }) }

, }); }), @@ -834,8 +779,7 @@ export const Commands = [ command: 'devtools', description: _td('Opens the Developer Tools dialog'), runFn: function(roomId) { - const DevtoolsDialog = sdk.getComponent('dialogs.DevtoolsDialog'); - Modal.createDialog(DevtoolsDialog, {roomId}); + Modal.createDialog(DevtoolsDialog, { roomId }); return success(); }, category: CommandCategories.advanced, @@ -859,7 +803,7 @@ export const Commands = [ const iframe = embed.childNodes[0] as ChildElement; if (iframe.tagName.toLowerCase() === 'iframe' && iframe.attrs) { const srcAttr = iframe.attrs.find(a => a.name === 'src'); - console.log("Pulling URL out of iframe (embed code)"); + logger.log("Pulling URL out of iframe (embed code)"); widgetUrl = srcAttr.value; } } @@ -879,7 +823,7 @@ export const Commands = [ // Make the widget a Jitsi widget if it looks like a Jitsi widget const jitsiData = Jitsi.getInstance().parsePreferredConferenceUrl(widgetUrl); if (jitsiData) { - console.log("Making /addwidget widget a Jitsi conference"); + logger.log("Making /addwidget widget a Jitsi conference"); type = WidgetType.JITSI; name = "Jitsi Conference"; data = jitsiData; @@ -939,7 +883,6 @@ export const Commands = [ await cli.setDeviceVerified(userId, deviceId, true); // Tell the user we verified everything - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); Modal.createTrackedDialog('Slash Commands', 'Verified key', InfoDialog, { title: _t('Verified key'), description:
@@ -947,7 +890,7 @@ export const Commands = [ { _t('The signing key you provided matches the signing key you received ' + 'from %(userId)s\'s session %(deviceId)s. Session marked as verified.', - {userId, deviceId}) + { userId, deviceId }) }

, @@ -978,7 +921,7 @@ export const Commands = [ args: '', runFn: function(roomId, args) { if (!args) return reject(this.getUserId()); - return success(ContentHelpers.makeHtmlMessage(args, textToHtmlRainbow(args))); + return successSync(ContentHelpers.makeHtmlMessage(args, textToHtmlRainbow(args))); }, category: CommandCategories.messages, }), @@ -988,7 +931,7 @@ export const Commands = [ args: '', runFn: function(roomId, args) { if (!args) return reject(this.getUserId()); - return success(ContentHelpers.makeHtmlEmote(args, textToHtmlRainbow(args))); + return successSync(ContentHelpers.makeHtmlEmote(args, textToHtmlRainbow(args))); }, category: CommandCategories.messages, }), @@ -996,8 +939,6 @@ export const Commands = [ command: "help", description: _td("Displays list of commands with usages and descriptions"), runFn: function() { - const SlashCommandHelpDialog = sdk.getComponent('dialogs.SlashCommandHelpDialog'); - Modal.createTrackedDialog('Slash Commands', 'Help', SlashCommandHelpDialog); return success(); }, @@ -1015,9 +956,8 @@ export const Commands = [ const member = MatrixClientPeg.get().getRoom(roomId).getMember(userId); dis.dispatch({ action: Action.ViewUser, - // XXX: We should be using a real member object and not assuming what the - // receiver wants. - member: member || {userId}, + // XXX: We should be using a real member object and not assuming what the receiver wants. + member: member || { userId } as User, }); return success(); }, @@ -1073,7 +1013,7 @@ export const Commands = [ command: "msg", description: _td("Sends a message to the given user"), args: " ", - runFn: function(_, args) { + runFn: function(roomId, args) { if (args) { // matches the first whitespace delimited group and then the rest of the string const matches = args.match(/^(\S+?)(?: +(.*))?$/s); @@ -1169,16 +1109,16 @@ export const Commands = [ }; MatrixClientPeg.get().sendMessage(roomId, content); } - dis.dispatch({action: `effects.${effect.command}`}); + dis.dispatch({ action: `effects.${effect.command}` }); })()); }, category: CommandCategories.effects, - }) + }); }), ]; // build a map from names and aliases to the Command objects. -export const CommandMap = new Map(); +export const CommandMap = new Map(); Commands.forEach(cmd => { CommandMap.set(cmd.command, cmd); cmd.aliases.forEach(alias => { @@ -1186,15 +1126,15 @@ Commands.forEach(cmd => { }); }); -export function parseCommandString(input: string) { +export function parseCommandString(input: string): { cmd?: string, args?: string } { // trim any trailing whitespace, as it can confuse the parser for // IRC-style commands input = input.replace(/\s+$/, ''); if (input[0] !== '/') return {}; // not a command const bits = input.match(/^(\S+?)(?:[ \n]+((.|\n)*))?$/); - let cmd; - let args; + let cmd: string; + let args: string; if (bits) { cmd = bits[1].substring(1).toLowerCase(); args = bits[2]; @@ -1202,7 +1142,12 @@ export function parseCommandString(input: string) { cmd = input; } - return {cmd, args}; + return { cmd, args }; +} + +interface ICmd { + cmd?: Command; + args?: string; } /** @@ -1213,8 +1158,8 @@ export function parseCommandString(input: string) { * processing the command, or 'promise' if a request was sent out. * Returns null if the input didn't match a command. */ -export function getCommand(input: string) { - const {cmd, args} = parseCommandString(input); +export function getCommand(input: string): ICmd { + const { cmd, args } = parseCommandString(input); if (CommandMap.has(cmd) && CommandMap.get(cmd).isEnabled()) { return { diff --git a/src/Terms.ts b/src/Terms.ts index a6ea40a6e8..86d006c832 100644 --- a/src/Terms.ts +++ b/src/Terms.ts @@ -15,11 +15,14 @@ limitations under the License. */ import classNames from 'classnames'; +import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types'; -import {MatrixClientPeg} from './MatrixClientPeg'; +import { MatrixClientPeg } from './MatrixClientPeg'; import * as sdk from '.'; import Modal from './Modal'; +import { logger } from "matrix-js-sdk/src/logger"; + export class TermsNotSignedError extends Error {} /** @@ -32,7 +35,7 @@ export class Service { * @param {string} baseUrl The Base URL of the service (ie. before '/_matrix') * @param {string} accessToken The user's access token for the service */ - constructor(public serviceType: string, public baseUrl: string, public accessToken: string) { + constructor(public serviceType: SERVICE_TYPES, public baseUrl: string, public accessToken: string) { } } @@ -48,13 +51,13 @@ export interface Policy { } export type Policies = { - [policy: string]: Policy, + [policy: string]: Policy; }; export type TermsInteractionCallback = ( policiesAndServicePairs: { - service: Service, - policies: Policies, + service: Service; + policies: Policies; }[], agreedUrls: string[], extraClassNames?: string, @@ -117,7 +120,7 @@ export async function startTermsFlow( // but that is not a thing the API supports, so probably best to just show // things they've not agreed to yet. const unagreedPoliciesAndServicePairs = []; - for (const {service, policies} of policiesAndServicePairs) { + for (const { service, policies } of policiesAndServicePairs) { const unagreedPolicies = {}; for (const [policyName, policy] of Object.entries(policies)) { let policyAgreed = false; @@ -131,7 +134,7 @@ export async function startTermsFlow( if (!policyAgreed) unagreedPolicies[policyName] = policy; } if (Object.keys(unagreedPolicies).length > 0) { - unagreedPoliciesAndServicePairs.push({service, policies: unagreedPolicies}); + unagreedPoliciesAndServicePairs.push({ service, policies: unagreedPolicies }); } } @@ -139,16 +142,16 @@ export async function startTermsFlow( const numAcceptedBeforeAgreement = agreedUrlSet.size; if (unagreedPoliciesAndServicePairs.length > 0) { const newlyAgreedUrls = await interactionCallback(unagreedPoliciesAndServicePairs, [...agreedUrlSet]); - console.log("User has agreed to URLs", newlyAgreedUrls); + logger.log("User has agreed to URLs", newlyAgreedUrls); // Merge with previously agreed URLs newlyAgreedUrls.forEach(url => agreedUrlSet.add(url)); } else { - console.log("User has already agreed to all required policies"); + logger.log("User has already agreed to all required policies"); } // We only ever add to the set of URLs, so if anything has changed then we'd see a different length if (agreedUrlSet.size !== numAcceptedBeforeAgreement) { - const newAcceptedTerms = {accepted: Array.from(agreedUrlSet)}; + const newAcceptedTerms = { accepted: Array.from(agreedUrlSet) }; await MatrixClientPeg.get().setAccountData('m.accepted_terms', newAcceptedTerms); } @@ -180,14 +183,15 @@ export async function startTermsFlow( export function dialogTermsInteractionCallback( policiesAndServicePairs: { - service: Service, - policies: { [policy: string]: Policy }, + service: Service; + policies: { [policy: string]: Policy }; }[], agreedUrls: string[], extraClassNames?: string, ): Promise { return new Promise((resolve, reject) => { - console.log("Terms that need agreement", policiesAndServicePairs); + logger.log("Terms that need agreement", policiesAndServicePairs); + // FIXME: Using an import will result in test failures const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog"); Modal.createTrackedDialog('Terms of Service', '', TermsDialog, { diff --git a/src/TextForEvent.js b/src/TextForEvent.js deleted file mode 100644 index 86f9ff20f4..0000000000 --- a/src/TextForEvent.js +++ /dev/null @@ -1,611 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd - -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 {MatrixClientPeg} from './MatrixClientPeg'; -import { _t } from './languageHandler'; -import * as Roles from './Roles'; -import {isValid3pidInvite} from "./RoomInvite"; -import SettingsStore from "./settings/SettingsStore"; -import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList"; -import {WIDGET_LAYOUT_EVENT_TYPE} from "./stores/widgets/WidgetLayoutStore"; - -function textForMemberEvent(ev) { - // XXX: SYJS-16 "sender is sometimes null for join messages" - const senderName = ev.sender ? ev.sender.name : ev.getSender(); - const targetName = ev.target ? ev.target.name : ev.getStateKey(); - const prevContent = ev.getPrevContent(); - const content = ev.getContent(); - - const reason = content.reason ? (_t('Reason') + ': ' + content.reason) : ''; - switch (content.membership) { - case 'invite': { - const threePidContent = content.third_party_invite; - if (threePidContent) { - if (threePidContent.display_name) { - return _t('%(targetName)s accepted the invitation for %(displayName)s.', { - targetName, - displayName: threePidContent.display_name, - }); - } else { - return _t('%(targetName)s accepted an invitation.', {targetName}); - } - } else { - return _t('%(senderName)s invited %(targetName)s.', {senderName, targetName}); - } - } - case 'ban': - return _t('%(senderName)s banned %(targetName)s.', {senderName, targetName}) + ' ' + reason; - case 'join': - if (prevContent && prevContent.membership === 'join') { - if (prevContent.displayname && content.displayname && prevContent.displayname !== content.displayname) { - return _t('%(oldDisplayName)s changed their display name to %(displayName)s.', { - oldDisplayName: prevContent.displayname, - displayName: content.displayname, - }); - } else if (!prevContent.displayname && content.displayname) { - return _t('%(senderName)s set their display name to %(displayName)s.', { - senderName: ev.getSender(), - displayName: content.displayname, - }); - } else if (prevContent.displayname && !content.displayname) { - return _t('%(senderName)s removed their display name (%(oldDisplayName)s).', { - senderName, - oldDisplayName: prevContent.displayname, - }); - } else if (prevContent.avatar_url && !content.avatar_url) { - return _t('%(senderName)s removed their profile picture.', {senderName}); - } else if (prevContent.avatar_url && content.avatar_url && - prevContent.avatar_url !== content.avatar_url) { - return _t('%(senderName)s changed their profile picture.', {senderName}); - } else if (!prevContent.avatar_url && content.avatar_url) { - return _t('%(senderName)s set a profile picture.', {senderName}); - } else if (SettingsStore.getValue("showHiddenEventsInTimeline")) { - // This is a null rejoin, it will only be visible if the Labs option is enabled - return _t("%(senderName)s made no change.", {senderName}); - } else { - return ""; - } - } else { - if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key); - return _t('%(targetName)s joined the room.', {targetName}); - } - case 'leave': - if (ev.getSender() === ev.getStateKey()) { - if (prevContent.membership === "invite") { - return _t('%(targetName)s rejected the invitation.', {targetName}); - } else { - return _t('%(targetName)s left the room.', {targetName}); - } - } else if (prevContent.membership === "ban") { - return _t('%(senderName)s unbanned %(targetName)s.', {senderName, targetName}); - } else if (prevContent.membership === "invite") { - return _t('%(senderName)s withdrew %(targetName)s\'s invitation.', { - senderName, - targetName, - }) + ' ' + reason; - } else if (prevContent.membership === "join") { - return _t('%(senderName)s kicked %(targetName)s.', {senderName, targetName}) + ' ' + reason; - } else { - return ""; - } - } -} - -function textForTopicEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - return _t('%(senderDisplayName)s changed the topic to "%(topic)s".', { - senderDisplayName, - topic: ev.getContent().topic, - }); -} - -function textForRoomNameEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - - if (!ev.getContent().name || ev.getContent().name.trim().length === 0) { - return _t('%(senderDisplayName)s removed the room name.', {senderDisplayName}); - } - if (ev.getPrevContent().name) { - return _t('%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.', { - senderDisplayName, - oldRoomName: ev.getPrevContent().name, - newRoomName: ev.getContent().name, - }); - } - return _t('%(senderDisplayName)s changed the room name to %(roomName)s.', { - senderDisplayName, - roomName: ev.getContent().name, - }); -} - -function textForTombstoneEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - return _t('%(senderDisplayName)s upgraded this room.', {senderDisplayName}); -} - -function textForJoinRulesEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - switch (ev.getContent().join_rule) { - case "public": - return _t('%(senderDisplayName)s made the room public to whoever knows the link.', {senderDisplayName}); - case "invite": - return _t('%(senderDisplayName)s made the room invite only.', {senderDisplayName}); - default: - // The spec supports "knock" and "private", however nothing implements these. - return _t('%(senderDisplayName)s changed the join rule to %(rule)s', { - senderDisplayName, - rule: ev.getContent().join_rule, - }); - } -} - -function textForGuestAccessEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - switch (ev.getContent().guest_access) { - case "can_join": - return _t('%(senderDisplayName)s has allowed guests to join the room.', {senderDisplayName}); - case "forbidden": - return _t('%(senderDisplayName)s has prevented guests from joining the room.', {senderDisplayName}); - default: - // There's no other options we can expect, however just for safety's sake we'll do this. - return _t('%(senderDisplayName)s changed guest access to %(rule)s', { - senderDisplayName, - rule: ev.getContent().guest_access, - }); - } -} - -function textForRelatedGroupsEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - const groups = ev.getContent().groups || []; - const prevGroups = ev.getPrevContent().groups || []; - const added = groups.filter((g) => !prevGroups.includes(g)); - const removed = prevGroups.filter((g) => !groups.includes(g)); - - if (added.length && !removed.length) { - return _t('%(senderDisplayName)s enabled flair for %(groups)s in this room.', { - senderDisplayName, - groups: added.join(', '), - }); - } else if (!added.length && removed.length) { - return _t('%(senderDisplayName)s disabled flair for %(groups)s in this room.', { - senderDisplayName, - groups: removed.join(', '), - }); - } else if (added.length && removed.length) { - return _t('%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for ' + - '%(oldGroups)s in this room.', { - senderDisplayName, - newGroups: added.join(', '), - oldGroups: removed.join(', '), - }); - } else { - // Don't bother rendering this change (because there were no changes) - return ''; - } -} - -function textForServerACLEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - const prevContent = ev.getPrevContent(); - const current = ev.getContent(); - const prev = { - deny: Array.isArray(prevContent.deny) ? prevContent.deny : [], - allow: Array.isArray(prevContent.allow) ? prevContent.allow : [], - allow_ip_literals: !(prevContent.allow_ip_literals === false), - }; - - let text = ""; - if (prev.deny.length === 0 && prev.allow.length === 0) { - text = _t("%(senderDisplayName)s set the server ACLs for this room.", {senderDisplayName}); - } else { - text = _t("%(senderDisplayName)s changed the server ACLs for this room.", {senderDisplayName}); - } - - if (!Array.isArray(current.allow)) { - current.allow = []; - } - - // If we know for sure everyone is banned, mark the room as obliterated - if (current.allow.length === 0) { - return text + " " + _t("🎉 All servers are banned from participating! This room can no longer be used."); - } - - return text; -} - -function textForMessageEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - let message = senderDisplayName + ': ' + ev.getContent().body; - if (ev.getContent().msgtype === "m.emote") { - message = "* " + senderDisplayName + " " + message; - } else if (ev.getContent().msgtype === "m.image") { - message = _t('%(senderDisplayName)s sent an image.', {senderDisplayName}); - } - return message; -} - -function textForCanonicalAliasEvent(ev) { - const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - const oldAlias = ev.getPrevContent().alias; - const oldAltAliases = ev.getPrevContent().alt_aliases || []; - const newAlias = ev.getContent().alias; - const newAltAliases = ev.getContent().alt_aliases || []; - const removedAltAliases = oldAltAliases.filter(alias => !newAltAliases.includes(alias)); - const addedAltAliases = newAltAliases.filter(alias => !oldAltAliases.includes(alias)); - - if (!removedAltAliases.length && !addedAltAliases.length) { - if (newAlias) { - return _t('%(senderName)s set the main address for this room to %(address)s.', { - senderName: senderName, - address: ev.getContent().alias, - }); - } else if (oldAlias) { - return _t('%(senderName)s removed the main address for this room.', { - senderName: senderName, - }); - } - } else if (newAlias === oldAlias) { - if (addedAltAliases.length && !removedAltAliases.length) { - return _t('%(senderName)s added the alternative addresses %(addresses)s for this room.', { - senderName: senderName, - addresses: addedAltAliases.join(", "), - count: addedAltAliases.length, - }); - } if (removedAltAliases.length && !addedAltAliases.length) { - return _t('%(senderName)s removed the alternative addresses %(addresses)s for this room.', { - senderName: senderName, - addresses: removedAltAliases.join(", "), - count: removedAltAliases.length, - }); - } if (removedAltAliases.length && addedAltAliases.length) { - return _t('%(senderName)s changed the alternative addresses for this room.', { - senderName: senderName, - }); - } - } else { - // both alias and alt_aliases where modified - return _t('%(senderName)s changed the main and alternative addresses for this room.', { - senderName: senderName, - }); - } - // in case there is no difference between the two events, - // say something as we can't simply hide the tile from here - return _t('%(senderName)s changed the addresses for this room.', { - senderName: senderName, - }); -} - -function textForCallAnswerEvent(event) { - const senderName = event.sender ? event.sender.name : _t('Someone'); - const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)'); - return _t('%(senderName)s answered the call.', {senderName}) + ' ' + supported; -} - -function textForCallHangupEvent(event) { - const senderName = event.sender ? event.sender.name : _t('Someone'); - const eventContent = event.getContent(); - let reason = ""; - if (!MatrixClientPeg.get().supportsVoip()) { - reason = _t('(not supported by this browser)'); - } else if (eventContent.reason) { - if (eventContent.reason === "ice_failed") { - // We couldn't establish a connection at all - reason = _t('(could not connect media)'); - } else if (eventContent.reason === "ice_timeout") { - // We established a connection but it died - reason = _t('(connection failed)'); - } else if (eventContent.reason === "user_media_failed") { - // The other side couldn't open capture devices - reason = _t("(their device couldn't start the camera / microphone)"); - } else if (eventContent.reason === "unknown_error") { - // An error code the other side doesn't have a way to express - // (as opposed to an error code they gave but we don't know about, - // in which case we show the error code) - reason = _t("(an error occurred)"); - } else if (eventContent.reason === "invite_timeout") { - reason = _t('(no answer)'); - } else if (eventContent.reason === "user hangup" || eventContent.reason === "user_hangup") { - // workaround for https://github.com/vector-im/element-web/issues/5178 - // it seems Android randomly sets a reason of "user hangup" which is - // interpreted as an error code :( - // https://github.com/vector-im/riot-android/issues/2623 - // Also the correct hangup code as of VoIP v1 (with underscore) - reason = ''; - } else { - reason = _t('(unknown failure: %(reason)s)', {reason: eventContent.reason}); - } - } - return _t('%(senderName)s ended the call.', {senderName}) + ' ' + reason; -} - -function textForCallRejectEvent(event) { - const senderName = event.sender ? event.sender.name : _t('Someone'); - return _t('%(senderName)s declined the call.', {senderName}); -} - -function textForCallInviteEvent(event) { - const senderName = event.sender ? event.sender.name : _t('Someone'); - // FIXME: Find a better way to determine this from the event? - let isVoice = true; - if (event.getContent().offer && event.getContent().offer.sdp && - event.getContent().offer.sdp.indexOf('m=video') !== -1) { - isVoice = false; - } - const isSupported = MatrixClientPeg.get().supportsVoip(); - - // This ladder could be reduced down to a couple string variables, however other languages - // can have a hard time translating those strings. In an effort to make translations easier - // and more accurate, we break out the string-based variables to a couple booleans. - if (isVoice && isSupported) { - return _t("%(senderName)s placed a voice call.", {senderName}); - } else if (isVoice && !isSupported) { - return _t("%(senderName)s placed a voice call. (not supported by this browser)", {senderName}); - } else if (!isVoice && isSupported) { - return _t("%(senderName)s placed a video call.", {senderName}); - } else if (!isVoice && !isSupported) { - return _t("%(senderName)s placed a video call. (not supported by this browser)", {senderName}); - } -} - -function textForThreePidInviteEvent(event) { - const senderName = event.sender ? event.sender.name : event.getSender(); - - if (!isValid3pidInvite(event)) { - const targetDisplayName = event.getPrevContent().display_name || _t("Someone"); - return _t('%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.', { - senderName, - targetDisplayName, - }); - } - - return _t('%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.', { - senderName, - targetDisplayName: event.getContent().display_name, - }); -} - -function textForHistoryVisibilityEvent(event) { - const senderName = event.sender ? event.sender.name : event.getSender(); - switch (event.getContent().history_visibility) { - case 'invited': - return _t('%(senderName)s made future room history visible to all room members, ' - + 'from the point they are invited.', {senderName}); - case 'joined': - return _t('%(senderName)s made future room history visible to all room members, ' - + 'from the point they joined.', {senderName}); - case 'shared': - return _t('%(senderName)s made future room history visible to all room members.', {senderName}); - case 'world_readable': - return _t('%(senderName)s made future room history visible to anyone.', {senderName}); - default: - return _t('%(senderName)s made future room history visible to unknown (%(visibility)s).', { - senderName, - visibility: event.getContent().history_visibility, - }); - } -} - -// Currently will only display a change if a user's power level is changed -function textForPowerEvent(event) { - const senderName = event.sender ? event.sender.name : event.getSender(); - if (!event.getPrevContent() || !event.getPrevContent().users || - !event.getContent() || !event.getContent().users) { - return ''; - } - const userDefault = event.getContent().users_default || 0; - // Construct set of userIds - const users = []; - Object.keys(event.getContent().users).forEach( - (userId) => { - if (users.indexOf(userId) === -1) users.push(userId); - }, - ); - Object.keys(event.getPrevContent().users).forEach( - (userId) => { - if (users.indexOf(userId) === -1) users.push(userId); - }, - ); - const diff = []; - // XXX: This is also surely broken for i18n - users.forEach((userId) => { - // Previous power level - const from = event.getPrevContent().users[userId]; - // Current power level - const to = event.getContent().users[userId]; - if (to !== from) { - diff.push( - _t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', { - userId, - fromPowerLevel: Roles.textualPowerLevel(from, userDefault), - toPowerLevel: Roles.textualPowerLevel(to, userDefault), - }), - ); - } - }); - if (!diff.length) { - return ''; - } - return _t('%(senderName)s changed the power level of %(powerLevelDiffText)s.', { - senderName, - powerLevelDiffText: diff.join(", "), - }); -} - -function textForPinnedEvent(event) { - const senderName = event.sender ? event.sender.name : event.getSender(); - return _t("%(senderName)s changed the pinned messages for the room.", {senderName}); -} - -function textForWidgetEvent(event) { - const senderName = event.getSender(); - const {name: prevName, type: prevType, url: prevUrl} = event.getPrevContent(); - const {name, type, url} = event.getContent() || {}; - - let widgetName = name || prevName || type || prevType || ''; - // Apply sentence case to widget name - if (widgetName && widgetName.length > 0) { - widgetName = widgetName[0].toUpperCase() + widgetName.slice(1); - } - - // If the widget was removed, its content should be {}, but this is sufficiently - // equivalent to that condition. - if (url) { - if (prevUrl) { - return _t('%(widgetName)s widget modified by %(senderName)s', { - widgetName, senderName, - }); - } else { - return _t('%(widgetName)s widget added by %(senderName)s', { - widgetName, senderName, - }); - } - } else { - return _t('%(widgetName)s widget removed by %(senderName)s', { - widgetName, senderName, - }); - } -} - -function textForWidgetLayoutEvent(event) { - const senderName = event.sender?.name || event.getSender(); - return _t("%(senderName)s has updated the widget layout", {senderName}); -} - -function textForMjolnirEvent(event) { - const senderName = event.getSender(); - const {entity: prevEntity} = event.getPrevContent(); - const {entity, recommendation, reason} = event.getContent(); - - // Rule removed - if (!entity) { - if (USER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s removed the rule banning users matching %(glob)s", - {senderName, glob: prevEntity}); - } else if (ROOM_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s removed the rule banning rooms matching %(glob)s", - {senderName, glob: prevEntity}); - } else if (SERVER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s removed the rule banning servers matching %(glob)s", - {senderName, glob: prevEntity}); - } - - // Unknown type. We'll say something, but we shouldn't end up here. - return _t("%(senderName)s removed a ban rule matching %(glob)s", {senderName, glob: prevEntity}); - } - - // Invalid rule - if (!recommendation || !reason) return _t(`%(senderName)s updated an invalid ban rule`, {senderName}); - - // Rule updated - if (entity === prevEntity) { - if (USER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s updated the rule banning users matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } else if (ROOM_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } else if (SERVER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } - - // Unknown type. We'll say something but we shouldn't end up here. - return _t("%(senderName)s updated a ban rule matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } - - // New rule - if (!prevEntity) { - if (USER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s created a rule banning users matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } else if (ROOM_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } else if (SERVER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } - - // Unknown type. We'll say something but we shouldn't end up here. - return _t("%(senderName)s created a ban rule matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } - - // else the entity !== prevEntity - count as a removal & add - if (USER_RULE_TYPES.includes(event.getType())) { - return _t( - "%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching " + - "%(newGlob)s for %(reason)s", - {senderName, oldGlob: prevEntity, newGlob: entity, reason}, - ); - } else if (ROOM_RULE_TYPES.includes(event.getType())) { - return _t( - "%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching " + - "%(newGlob)s for %(reason)s", - {senderName, oldGlob: prevEntity, newGlob: entity, reason}, - ); - } else if (SERVER_RULE_TYPES.includes(event.getType())) { - return _t( - "%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching " + - "%(newGlob)s for %(reason)s", - {senderName, oldGlob: prevEntity, newGlob: entity, reason}, - ); - } - - // Unknown type. We'll say something but we shouldn't end up here. - return _t("%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s " + - "for %(reason)s", {senderName, oldGlob: prevEntity, newGlob: entity, reason}); -} - -const handlers = { - 'm.room.message': textForMessageEvent, - 'm.call.invite': textForCallInviteEvent, - 'm.call.answer': textForCallAnswerEvent, - 'm.call.hangup': textForCallHangupEvent, - 'm.call.reject': textForCallRejectEvent, -}; - -const stateHandlers = { - 'm.room.canonical_alias': textForCanonicalAliasEvent, - 'm.room.name': textForRoomNameEvent, - 'm.room.topic': textForTopicEvent, - 'm.room.member': textForMemberEvent, - 'm.room.third_party_invite': textForThreePidInviteEvent, - 'm.room.history_visibility': textForHistoryVisibilityEvent, - 'm.room.power_levels': textForPowerEvent, - 'm.room.pinned_events': textForPinnedEvent, - 'm.room.server_acl': textForServerACLEvent, - 'm.room.tombstone': textForTombstoneEvent, - 'm.room.join_rules': textForJoinRulesEvent, - 'm.room.guest_access': textForGuestAccessEvent, - 'm.room.related_groups': textForRelatedGroupsEvent, - - // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) - 'im.vector.modular.widgets': textForWidgetEvent, - [WIDGET_LAYOUT_EVENT_TYPE]: textForWidgetLayoutEvent, -}; - -// Add all the Mjolnir stuff to the renderer -for (const evType of ALL_RULE_TYPES) { - stateHandlers[evType] = textForMjolnirEvent; -} - -export function textForEvent(ev) { - const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; - if (handler) return handler(ev); - return ''; -} diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx new file mode 100644 index 0000000000..6fb4107d20 --- /dev/null +++ b/src/TextForEvent.tsx @@ -0,0 +1,746 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd + +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 { _t } from './languageHandler'; +import * as Roles from './Roles'; +import { isValid3pidInvite } from "./RoomInvite"; +import SettingsStore from "./settings/SettingsStore"; +import { ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES } from "./mjolnir/BanList"; +import { WIDGET_LAYOUT_EVENT_TYPE } from "./stores/widgets/WidgetLayoutStore"; +import { RightPanelPhases } from './stores/RightPanelStorePhases'; +import { Action } from './dispatcher/actions'; +import defaultDispatcher from './dispatcher/dispatcher'; +import { SetRightPanelPhasePayload } from './dispatcher/payloads/SetRightPanelPhasePayload'; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { MatrixClientPeg } from "./MatrixClientPeg"; + +// These functions are frequently used just to check whether an event has +// any text to display at all. For this reason they return deferred values +// to avoid the expense of looking up translations when they're not needed. + +function textForCallInviteEvent(event: MatrixEvent): () => string | null { + const getSenderName = () => event.sender ? event.sender.name : _t('Someone'); + // FIXME: Find a better way to determine this from the event? + let isVoice = true; + if (event.getContent().offer && event.getContent().offer.sdp && + event.getContent().offer.sdp.indexOf('m=video') !== -1) { + isVoice = false; + } + const isSupported = MatrixClientPeg.get().supportsVoip(); + + // This ladder could be reduced down to a couple string variables, however other languages + // can have a hard time translating those strings. In an effort to make translations easier + // and more accurate, we break out the string-based variables to a couple booleans. + if (isVoice && isSupported) { + return () => _t("%(senderName)s placed a voice call.", { + senderName: getSenderName(), + }); + } else if (isVoice && !isSupported) { + return () => _t("%(senderName)s placed a voice call. (not supported by this browser)", { + senderName: getSenderName(), + }); + } else if (!isVoice && isSupported) { + return () => _t("%(senderName)s placed a video call.", { + senderName: getSenderName(), + }); + } else if (!isVoice && !isSupported) { + return () => _t("%(senderName)s placed a video call. (not supported by this browser)", { + senderName: getSenderName(), + }); + } +} + +function textForMemberEvent(ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean): () => string | null { + // XXX: SYJS-16 "sender is sometimes null for join messages" + const senderName = ev.sender ? ev.sender.name : ev.getSender(); + const targetName = ev.target ? ev.target.name : ev.getStateKey(); + const prevContent = ev.getPrevContent(); + const content = ev.getContent(); + const reason = content.reason; + + switch (content.membership) { + case 'invite': { + const threePidContent = content.third_party_invite; + if (threePidContent) { + if (threePidContent.display_name) { + return () => _t('%(targetName)s accepted the invitation for %(displayName)s', { + targetName, + displayName: threePidContent.display_name, + }); + } else { + return () => _t('%(targetName)s accepted an invitation', { targetName }); + } + } else { + return () => _t('%(senderName)s invited %(targetName)s', { senderName, targetName }); + } + } + case 'ban': + return () => reason + ? _t('%(senderName)s banned %(targetName)s: %(reason)s', { senderName, targetName, reason }) + : _t('%(senderName)s banned %(targetName)s', { senderName, targetName }); + case 'join': + if (prevContent && prevContent.membership === 'join') { + if (prevContent.displayname && content.displayname && prevContent.displayname !== content.displayname) { + return () => _t('%(oldDisplayName)s changed their display name to %(displayName)s', { + oldDisplayName: prevContent.displayname, + displayName: content.displayname, + }); + } else if (!prevContent.displayname && content.displayname) { + return () => _t('%(senderName)s set their display name to %(displayName)s', { + senderName: ev.getSender(), + displayName: content.displayname, + }); + } else if (prevContent.displayname && !content.displayname) { + return () => _t('%(senderName)s removed their display name (%(oldDisplayName)s)', { + senderName, + oldDisplayName: prevContent.displayname, + }); + } else if (prevContent.avatar_url && !content.avatar_url) { + return () => _t('%(senderName)s removed their profile picture', { senderName }); + } else if (prevContent.avatar_url && content.avatar_url && + prevContent.avatar_url !== content.avatar_url) { + return () => _t('%(senderName)s changed their profile picture', { senderName }); + } else if (!prevContent.avatar_url && content.avatar_url) { + return () => _t('%(senderName)s set a profile picture', { senderName }); + } else if (showHiddenEvents ?? SettingsStore.getValue("showHiddenEventsInTimeline")) { + // This is a null rejoin, it will only be visible if using 'show hidden events' (labs) + return () => _t("%(senderName)s made no change", { senderName }); + } else { + return null; + } + } else { + if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key); + return () => _t('%(targetName)s joined the room', { targetName }); + } + case 'leave': + if (ev.getSender() === ev.getStateKey()) { + if (prevContent.membership === "invite") { + return () => _t('%(targetName)s rejected the invitation', { targetName }); + } else { + return () => reason + ? _t('%(targetName)s left the room: %(reason)s', { targetName, reason }) + : _t('%(targetName)s left the room', { targetName }); + } + } else if (prevContent.membership === "ban") { + return () => _t('%(senderName)s unbanned %(targetName)s', { senderName, targetName }); + } else if (prevContent.membership === "invite") { + return () => reason + ? _t('%(senderName)s withdrew %(targetName)s\'s invitation: %(reason)s', { + senderName, + targetName, + reason, + }) + : _t('%(senderName)s withdrew %(targetName)s\'s invitation', { senderName, targetName }); + } else if (prevContent.membership === "join") { + return () => reason + ? _t('%(senderName)s kicked %(targetName)s: %(reason)s', { + senderName, + targetName, + reason, + }) + : _t('%(senderName)s kicked %(targetName)s', { senderName, targetName }); + } else { + return null; + } + } +} + +function textForTopicEvent(ev: MatrixEvent): () => string | null { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + return () => _t('%(senderDisplayName)s changed the topic to "%(topic)s".', { + senderDisplayName, + topic: ev.getContent().topic, + }); +} + +function textForRoomAvatarEvent(ev: MatrixEvent): () => string | null { + const senderDisplayName = ev?.sender?.name || ev.getSender(); + return () => _t('%(senderDisplayName)s changed the room avatar.', { senderDisplayName }); +} + +function textForRoomNameEvent(ev: MatrixEvent): () => string | null { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + + if (!ev.getContent().name || ev.getContent().name.trim().length === 0) { + return () => _t('%(senderDisplayName)s removed the room name.', { senderDisplayName }); + } + if (ev.getPrevContent().name) { + return () => _t('%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.', { + senderDisplayName, + oldRoomName: ev.getPrevContent().name, + newRoomName: ev.getContent().name, + }); + } + return () => _t('%(senderDisplayName)s changed the room name to %(roomName)s.', { + senderDisplayName, + roomName: ev.getContent().name, + }); +} + +function textForTombstoneEvent(ev: MatrixEvent): () => string | null { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + return () => _t('%(senderDisplayName)s upgraded this room.', { senderDisplayName }); +} + +function textForJoinRulesEvent(ev: MatrixEvent): () => string | null { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + switch (ev.getContent().join_rule) { + case "public": + return () => _t('%(senderDisplayName)s made the room public to whoever knows the link.', { + senderDisplayName, + }); + case "invite": + return () => _t('%(senderDisplayName)s made the room invite only.', { + senderDisplayName, + }); + default: + // The spec supports "knock" and "private", however nothing implements these. + return () => _t('%(senderDisplayName)s changed the join rule to %(rule)s', { + senderDisplayName, + rule: ev.getContent().join_rule, + }); + } +} + +function textForGuestAccessEvent(ev: MatrixEvent): () => string | null { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + switch (ev.getContent().guest_access) { + case "can_join": + return () => _t('%(senderDisplayName)s has allowed guests to join the room.', { senderDisplayName }); + case "forbidden": + return () => _t('%(senderDisplayName)s has prevented guests from joining the room.', { senderDisplayName }); + default: + // There's no other options we can expect, however just for safety's sake we'll do this. + return () => _t('%(senderDisplayName)s changed guest access to %(rule)s', { + senderDisplayName, + rule: ev.getContent().guest_access, + }); + } +} + +function textForRelatedGroupsEvent(ev: MatrixEvent): () => string | null { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + const groups = ev.getContent().groups || []; + const prevGroups = ev.getPrevContent().groups || []; + const added = groups.filter((g) => !prevGroups.includes(g)); + const removed = prevGroups.filter((g) => !groups.includes(g)); + + if (added.length && !removed.length) { + return () => _t('%(senderDisplayName)s enabled flair for %(groups)s in this room.', { + senderDisplayName, + groups: added.join(', '), + }); + } else if (!added.length && removed.length) { + return () => _t('%(senderDisplayName)s disabled flair for %(groups)s in this room.', { + senderDisplayName, + groups: removed.join(', '), + }); + } else if (added.length && removed.length) { + return () => _t('%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for ' + + '%(oldGroups)s in this room.', { + senderDisplayName, + newGroups: added.join(', '), + oldGroups: removed.join(', '), + }); + } else { + // Don't bother rendering this change (because there were no changes) + return null; + } +} + +function textForServerACLEvent(ev: MatrixEvent): () => string | null { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + const prevContent = ev.getPrevContent(); + const current = ev.getContent(); + const prev = { + deny: Array.isArray(prevContent.deny) ? prevContent.deny : [], + allow: Array.isArray(prevContent.allow) ? prevContent.allow : [], + allow_ip_literals: !(prevContent.allow_ip_literals === false), + }; + + let getText = null; + if (prev.deny.length === 0 && prev.allow.length === 0) { + getText = () => _t("%(senderDisplayName)s set the server ACLs for this room.", { senderDisplayName }); + } else { + getText = () => _t("%(senderDisplayName)s changed the server ACLs for this room.", { senderDisplayName }); + } + + if (!Array.isArray(current.allow)) { + current.allow = []; + } + + // If we know for sure everyone is banned, mark the room as obliterated + if (current.allow.length === 0) { + return () => getText() + " " + + _t("🎉 All servers are banned from participating! This room can no longer be used."); + } + + return getText; +} + +function textForMessageEvent(ev: MatrixEvent): () => string | null { + return () => { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + let message = ev.getContent().body; + if (ev.isRedacted()) { + message = _t("Message deleted"); + const unsigned = ev.getUnsigned(); + const redactedBecauseUserId = unsigned?.redacted_because?.sender; + if (redactedBecauseUserId && redactedBecauseUserId !== ev.getSender()) { + const room = MatrixClientPeg.get().getRoom(ev.getRoomId()); + const sender = room?.getMember(redactedBecauseUserId); + message = _t("Message deleted by %(name)s", { name: sender?.name + || redactedBecauseUserId }); + } + } + if (ev.getContent().msgtype === "m.emote") { + message = "* " + senderDisplayName + " " + message; + } else if (ev.getContent().msgtype === "m.image") { + message = _t('%(senderDisplayName)s sent an image.', { senderDisplayName }); + } else if (ev.getType() == "m.sticker") { + message = _t('%(senderDisplayName)s sent a sticker.', { senderDisplayName }); + } else { + // in this case, parse it as a plain text message + message = senderDisplayName + ': ' + message; + } + return message; + }; +} + +function textForCanonicalAliasEvent(ev: MatrixEvent): () => string | null { + const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + const oldAlias = ev.getPrevContent().alias; + const oldAltAliases = ev.getPrevContent().alt_aliases || []; + const newAlias = ev.getContent().alias; + const newAltAliases = ev.getContent().alt_aliases || []; + const removedAltAliases = oldAltAliases.filter(alias => !newAltAliases.includes(alias)); + const addedAltAliases = newAltAliases.filter(alias => !oldAltAliases.includes(alias)); + + if (!removedAltAliases.length && !addedAltAliases.length) { + if (newAlias) { + return () => _t('%(senderName)s set the main address for this room to %(address)s.', { + senderName: senderName, + address: ev.getContent().alias, + }); + } else if (oldAlias) { + return () => _t('%(senderName)s removed the main address for this room.', { + senderName: senderName, + }); + } + } else if (newAlias === oldAlias) { + if (addedAltAliases.length && !removedAltAliases.length) { + return () => _t('%(senderName)s added the alternative addresses %(addresses)s for this room.', { + senderName: senderName, + addresses: addedAltAliases.join(", "), + count: addedAltAliases.length, + }); + } if (removedAltAliases.length && !addedAltAliases.length) { + return () => _t('%(senderName)s removed the alternative addresses %(addresses)s for this room.', { + senderName: senderName, + addresses: removedAltAliases.join(", "), + count: removedAltAliases.length, + }); + } if (removedAltAliases.length && addedAltAliases.length) { + return () => _t('%(senderName)s changed the alternative addresses for this room.', { + senderName: senderName, + }); + } + } else { + // both alias and alt_aliases where modified + return () => _t('%(senderName)s changed the main and alternative addresses for this room.', { + senderName: senderName, + }); + } + // in case there is no difference between the two events, + // say something as we can't simply hide the tile from here + return () => _t('%(senderName)s changed the addresses for this room.', { + senderName: senderName, + }); +} + +function textForThreePidInviteEvent(event: MatrixEvent): () => string | null { + const senderName = event.sender ? event.sender.name : event.getSender(); + + if (!isValid3pidInvite(event)) { + return () => _t('%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.', { + senderName, + targetDisplayName: event.getPrevContent().display_name || _t("Someone"), + }); + } + + return () => _t('%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.', { + senderName, + targetDisplayName: event.getContent().display_name, + }); +} + +function textForHistoryVisibilityEvent(event: MatrixEvent): () => string | null { + const senderName = event.sender ? event.sender.name : event.getSender(); + switch (event.getContent().history_visibility) { + case 'invited': + return () => _t('%(senderName)s made future room history visible to all room members, ' + + 'from the point they are invited.', { senderName }); + case 'joined': + return () => _t('%(senderName)s made future room history visible to all room members, ' + + 'from the point they joined.', { senderName }); + case 'shared': + return () => _t('%(senderName)s made future room history visible to all room members.', { senderName }); + case 'world_readable': + return () => _t('%(senderName)s made future room history visible to anyone.', { senderName }); + default: + return () => _t('%(senderName)s made future room history visible to unknown (%(visibility)s).', { + senderName, + visibility: event.getContent().history_visibility, + }); + } +} + +// Currently will only display a change if a user's power level is changed +function textForPowerEvent(event: MatrixEvent): () => string | null { + const senderName = event.sender ? event.sender.name : event.getSender(); + if (!event.getPrevContent() || !event.getPrevContent().users || + !event.getContent() || !event.getContent().users) { + return null; + } + const previousUserDefault = event.getPrevContent().users_default || 0; + const currentUserDefault = event.getContent().users_default || 0; + // Construct set of userIds + const users = []; + Object.keys(event.getContent().users).forEach( + (userId) => { + if (users.indexOf(userId) === -1) users.push(userId); + }, + ); + Object.keys(event.getPrevContent().users).forEach( + (userId) => { + if (users.indexOf(userId) === -1) users.push(userId); + }, + ); + const diffs = []; + users.forEach((userId) => { + // Previous power level + let from = event.getPrevContent().users[userId]; + if (!Number.isInteger(from)) { + from = previousUserDefault; + } + // Current power level + let to = event.getContent().users[userId]; + if (!Number.isInteger(to)) { + to = currentUserDefault; + } + if (from === previousUserDefault && to === currentUserDefault) { return; } + if (to !== from) { + diffs.push({ userId, from, to }); + } + }); + if (!diffs.length) { + return null; + } + // XXX: This is also surely broken for i18n + return () => _t('%(senderName)s changed the power level of %(powerLevelDiffText)s.', { + senderName, + powerLevelDiffText: diffs.map(diff => + _t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', { + userId: diff.userId, + fromPowerLevel: Roles.textualPowerLevel(diff.from, previousUserDefault), + toPowerLevel: Roles.textualPowerLevel(diff.to, currentUserDefault), + }), + ).join(", "), + }); +} + +const onPinnedOrUnpinnedMessageClick = (messageId: string, roomId: string): void => { + defaultDispatcher.dispatch({ + action: 'view_room', + event_id: messageId, + highlighted: true, + room_id: roomId, + }); +}; + +const onPinnedMessagesClick = (): void => { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.PinnedMessages, + allowClose: false, + }); +}; + +function textForPinnedEvent(event: MatrixEvent, allowJSX: boolean): () => string | JSX.Element | null { + if (!SettingsStore.getValue("feature_pinning")) return null; + const senderName = event.sender ? event.sender.name : event.getSender(); + const roomId = event.getRoomId(); + + const pinned = event.getContent().pinned ?? []; + const previouslyPinned = event.getPrevContent().pinned ?? []; + const newlyPinned = pinned.filter(item => previouslyPinned.indexOf(item) < 0); + const newlyUnpinned = previouslyPinned.filter(item => pinned.indexOf(item) < 0); + + if (newlyPinned.length === 1 && newlyUnpinned.length === 0) { + // A single message was pinned, include a link to that message. + if (allowJSX) { + const messageId = newlyPinned.pop(); + + return () => ( + + { _t( + "%(senderName)s pinned a message to this room. See all pinned messages.", + { senderName }, + { + "a": (sub) => + onPinnedOrUnpinnedMessageClick(messageId, roomId)}> + { sub } + , + "b": (sub) => + + { sub } + , + }, + ) } + + ); + } + + return () => _t("%(senderName)s pinned a message to this room. See all pinned messages.", { senderName }); + } + + if (newlyUnpinned.length === 1 && newlyPinned.length === 0) { + // A single message was unpinned, include a link to that message. + if (allowJSX) { + const messageId = newlyUnpinned.pop(); + + return () => ( + + { _t( + "%(senderName)s unpinned a message from this room. See all pinned messages.", + { senderName }, + { + "a": (sub) => + onPinnedOrUnpinnedMessageClick(messageId, roomId)}> + { sub } + , + "b": (sub) => + + { sub } + , + }, + ) } + + ); + } + + return () => _t("%(senderName)s unpinned a message from this room. See all pinned messages.", { senderName }); + } + + if (allowJSX) { + return () => ( + + { _t( + "%(senderName)s changed the pinned messages for the room.", + { senderName }, + { "a": (sub) => { sub } }, + ) } + + ); + } + + return () => _t("%(senderName)s changed the pinned messages for the room.", { senderName }); +} + +function textForWidgetEvent(event: MatrixEvent): () => string | null { + const senderName = event.getSender(); + const { name: prevName, type: prevType, url: prevUrl } = event.getPrevContent(); + const { name, type, url } = event.getContent() || {}; + + let widgetName = name || prevName || type || prevType || ''; + // Apply sentence case to widget name + if (widgetName && widgetName.length > 0) { + widgetName = widgetName[0].toUpperCase() + widgetName.slice(1); + } + + // If the widget was removed, its content should be {}, but this is sufficiently + // equivalent to that condition. + if (url) { + if (prevUrl) { + return () => _t('%(widgetName)s widget modified by %(senderName)s', { + widgetName, senderName, + }); + } else { + return () => _t('%(widgetName)s widget added by %(senderName)s', { + widgetName, senderName, + }); + } + } else { + return () => _t('%(widgetName)s widget removed by %(senderName)s', { + widgetName, senderName, + }); + } +} + +function textForWidgetLayoutEvent(event: MatrixEvent): () => string | null { + const senderName = event.sender?.name || event.getSender(); + return () => _t("%(senderName)s has updated the widget layout", { senderName }); +} + +function textForMjolnirEvent(event: MatrixEvent): () => string | null { + const senderName = event.getSender(); + const { entity: prevEntity } = event.getPrevContent(); + const { entity, recommendation, reason } = event.getContent(); + + // Rule removed + if (!entity) { + if (USER_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s removed the rule banning users matching %(glob)s", + { senderName, glob: prevEntity }); + } else if (ROOM_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s removed the rule banning rooms matching %(glob)s", + { senderName, glob: prevEntity }); + } else if (SERVER_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s removed the rule banning servers matching %(glob)s", + { senderName, glob: prevEntity }); + } + + // Unknown type. We'll say something, but we shouldn't end up here. + return () => _t("%(senderName)s removed a ban rule matching %(glob)s", { senderName, glob: prevEntity }); + } + + // Invalid rule + if (!recommendation || !reason) return () => _t(`%(senderName)s updated an invalid ban rule`, { senderName }); + + // Rule updated + if (entity === prevEntity) { + if (USER_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s updated the rule banning users matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } else if (ROOM_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } else if (SERVER_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } + + // Unknown type. We'll say something but we shouldn't end up here. + return () => _t("%(senderName)s updated a ban rule matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } + + // New rule + if (!prevEntity) { + if (USER_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s created a rule banning users matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } else if (ROOM_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } else if (SERVER_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } + + // Unknown type. We'll say something but we shouldn't end up here. + return () => _t("%(senderName)s created a ban rule matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } + + // else the entity !== prevEntity - count as a removal & add + if (USER_RULE_TYPES.includes(event.getType())) { + return () => _t( + "%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching " + + "%(newGlob)s for %(reason)s", + { senderName, oldGlob: prevEntity, newGlob: entity, reason }, + ); + } else if (ROOM_RULE_TYPES.includes(event.getType())) { + return () => _t( + "%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching " + + "%(newGlob)s for %(reason)s", + { senderName, oldGlob: prevEntity, newGlob: entity, reason }, + ); + } else if (SERVER_RULE_TYPES.includes(event.getType())) { + return () => _t( + "%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching " + + "%(newGlob)s for %(reason)s", + { senderName, oldGlob: prevEntity, newGlob: entity, reason }, + ); + } + + // Unknown type. We'll say something but we shouldn't end up here. + return () => _t("%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s " + + "for %(reason)s", { senderName, oldGlob: prevEntity, newGlob: entity, reason }); +} + +interface IHandlers { + [type: string]: + (ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean) => + (() => string | JSX.Element | null); +} + +const handlers: IHandlers = { + 'm.room.message': textForMessageEvent, + 'm.sticker': textForMessageEvent, + 'm.call.invite': textForCallInviteEvent, +}; + +const stateHandlers: IHandlers = { + 'm.room.canonical_alias': textForCanonicalAliasEvent, + 'm.room.name': textForRoomNameEvent, + 'm.room.topic': textForTopicEvent, + 'm.room.member': textForMemberEvent, + "m.room.avatar": textForRoomAvatarEvent, + 'm.room.third_party_invite': textForThreePidInviteEvent, + 'm.room.history_visibility': textForHistoryVisibilityEvent, + 'm.room.power_levels': textForPowerEvent, + 'm.room.pinned_events': textForPinnedEvent, + 'm.room.server_acl': textForServerACLEvent, + 'm.room.tombstone': textForTombstoneEvent, + 'm.room.join_rules': textForJoinRulesEvent, + 'm.room.guest_access': textForGuestAccessEvent, + 'm.room.related_groups': textForRelatedGroupsEvent, + + // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) + 'im.vector.modular.widgets': textForWidgetEvent, + [WIDGET_LAYOUT_EVENT_TYPE]: textForWidgetLayoutEvent, +}; + +// Add all the Mjolnir stuff to the renderer +for (const evType of ALL_RULE_TYPES) { + stateHandlers[evType] = textForMjolnirEvent; +} + +/** + * Determines whether the given event has text to display. + * @param ev The event + * @param showHiddenEvents An optional cached setting value for showHiddenEventsInTimeline + * to avoid hitting the settings store + */ +export function hasText(ev: MatrixEvent, showHiddenEvents?: boolean): boolean { + const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; + return Boolean(handler?.(ev, false, showHiddenEvents)); +} + +/** + * Gets the textual content of the given event. + * @param ev The event + * @param allowJSX Whether to output rich JSX content + * @param showHiddenEvents An optional cached setting value for showHiddenEventsInTimeline + * to avoid hitting the settings store + */ +export function textForEvent(ev: MatrixEvent): string; +export function textForEvent(ev: MatrixEvent, allowJSX: true, showHiddenEvents?: boolean): string | JSX.Element; +export function textForEvent(ev: MatrixEvent, allowJSX = false, showHiddenEvents?: boolean): string | JSX.Element { + const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; + return handler?.(ev, allowJSX, showHiddenEvents)?.() || ''; +} diff --git a/src/Tinter.js b/src/Tinter.js deleted file mode 100644 index ca5a460e16..0000000000 --- a/src/Tinter.js +++ /dev/null @@ -1,458 +0,0 @@ -/* -Copyright 2015 OpenMarket Ltd -Copyright 2017 New Vector Ltd - -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. -*/ - -const DEBUG = 0; - -// utility to turn #rrggbb or rgb(r,g,b) into [red,green,blue] -function colorToRgb(color) { - if (!color) { - return [0, 0, 0]; - } - - if (color[0] === '#') { - color = color.slice(1); - if (color.length === 3) { - color = color[0] + color[0] + - color[1] + color[1] + - color[2] + color[2]; - } - const val = parseInt(color, 16); - const r = (val >> 16) & 255; - const g = (val >> 8) & 255; - const b = val & 255; - return [r, g, b]; - } else { - const match = color.match(/rgb\((.*?),(.*?),(.*?)\)/); - if (match) { - return [ - parseInt(match[1]), - parseInt(match[2]), - parseInt(match[3]), - ]; - } - } - return [0, 0, 0]; -} - -// utility to turn [red,green,blue] into #rrggbb -function rgbToColor(rgb) { - const val = (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]; - return '#' + (0x1000000 + val).toString(16).slice(1); -} - -class Tinter { - constructor() { - // The default colour keys to be replaced as referred to in CSS - // (should be overridden by .mx_theme_accentColor and .mx_theme_secondaryAccentColor) - this.keyRgb = [ - "rgb(118, 207, 166)", // Vector Green - "rgb(234, 245, 240)", // Vector Light Green - "rgb(211, 239, 225)", // roomsublist-label-bg-color (20% Green overlaid on Light Green) - ]; - - // Some algebra workings for calculating the tint % of Vector Green & Light Green - // x * 118 + (1 - x) * 255 = 234 - // x * 118 + 255 - 255 * x = 234 - // x * 118 - x * 255 = 234 - 255 - // (255 - 118) x = 255 - 234 - // x = (255 - 234) / (255 - 118) = 0.16 - - // The colour keys to be replaced as referred to in SVGs - this.keyHex = [ - "#76CFA6", // Vector Green - "#EAF5F0", // Vector Light Green - "#D3EFE1", // roomsublist-label-bg-color (20% Green overlaid on Light Green) - "#FFFFFF", // white highlights of the SVGs (for switching to dark theme) - "#000000", // black lowlights of the SVGs (for switching to dark theme) - ]; - - // track the replacement colours actually being used - // defaults to our keys. - this.colors = [ - this.keyHex[0], - this.keyHex[1], - this.keyHex[2], - this.keyHex[3], - this.keyHex[4], - ]; - - // track the most current tint request inputs (which may differ from the - // end result stored in this.colors - this.currentTint = [ - undefined, - undefined, - undefined, - undefined, - undefined, - ]; - - this.cssFixups = [ - // { theme: { - // style: a style object that should be fixed up taken from a stylesheet - // attr: name of the attribute to be clobbered, e.g. 'color' - // index: ordinal of primary, secondary or tertiary - // }, - // } - ]; - - // CSS attributes to be fixed up - this.cssAttrs = [ - "color", - "backgroundColor", - "borderColor", - "borderTopColor", - "borderBottomColor", - "borderLeftColor", - ]; - - this.svgAttrs = [ - "fill", - "stroke", - ]; - - // List of functions to call when the tint changes. - this.tintables = []; - - // the currently loaded theme (if any) - this.theme = undefined; - - // whether to force a tint (e.g. after changing theme) - this.forceTint = false; - } - - /** - * Register a callback to fire when the tint changes. - * This is used to rewrite the tintable SVGs with the new tint. - * - * It's not possible to unregister a tintable callback. So this can only be - * used to register a static callback. If a set of tintables will change - * over time then the best bet is to register a single callback for the - * entire set. - * - * To ensure the tintable work happens at least once, it is also called as - * part of registration. - * - * @param {Function} tintable Function to call when the tint changes. - */ - registerTintable(tintable) { - this.tintables.push(tintable); - tintable(); - } - - getKeyRgb() { - return this.keyRgb; - } - - tint(primaryColor, secondaryColor, tertiaryColor) { - return; - // eslint-disable-next-line no-unreachable - this.currentTint[0] = primaryColor; - this.currentTint[1] = secondaryColor; - this.currentTint[2] = tertiaryColor; - - this.calcCssFixups(); - - if (DEBUG) { - console.log("Tinter.tint(" + primaryColor + ", " + - secondaryColor + ", " + - tertiaryColor + ")"); - } - - if (!primaryColor) { - primaryColor = this.keyRgb[0]; - secondaryColor = this.keyRgb[1]; - tertiaryColor = this.keyRgb[2]; - } - - if (!secondaryColor) { - const x = 0.16; // average weighting factor calculated from vector green & light green - const rgb = colorToRgb(primaryColor); - rgb[0] = x * rgb[0] + (1 - x) * 255; - rgb[1] = x * rgb[1] + (1 - x) * 255; - rgb[2] = x * rgb[2] + (1 - x) * 255; - secondaryColor = rgbToColor(rgb); - } - - if (!tertiaryColor) { - const x = 0.19; - const rgb1 = colorToRgb(primaryColor); - const rgb2 = colorToRgb(secondaryColor); - rgb1[0] = x * rgb1[0] + (1 - x) * rgb2[0]; - rgb1[1] = x * rgb1[1] + (1 - x) * rgb2[1]; - rgb1[2] = x * rgb1[2] + (1 - x) * rgb2[2]; - tertiaryColor = rgbToColor(rgb1); - } - - if (this.forceTint == false && - this.colors[0] === primaryColor && - this.colors[1] === secondaryColor && - this.colors[2] === tertiaryColor) { - return; - } - - this.forceTint = false; - - this.colors[0] = primaryColor; - this.colors[1] = secondaryColor; - this.colors[2] = tertiaryColor; - - if (DEBUG) { - console.log("Tinter.tint final: (" + primaryColor + ", " + - secondaryColor + ", " + - tertiaryColor + ")"); - } - - // go through manually fixing up the stylesheets. - this.applyCssFixups(); - - // tell all the SVGs to go fix themselves up - // we don't do this as a dispatch otherwise it will visually lag - this.tintables.forEach(function(tintable) { - tintable(); - }); - } - - tintSvgWhite(whiteColor) { - this.currentTint[3] = whiteColor; - - if (!whiteColor) { - whiteColor = this.colors[3]; - } - if (this.colors[3] === whiteColor) { - return; - } - this.colors[3] = whiteColor; - this.tintables.forEach(function(tintable) { - tintable(); - }); - } - - tintSvgBlack(blackColor) { - this.currentTint[4] = blackColor; - - if (!blackColor) { - blackColor = this.colors[4]; - } - if (this.colors[4] === blackColor) { - return; - } - this.colors[4] = blackColor; - this.tintables.forEach(function(tintable) { - tintable(); - }); - } - - - setTheme(theme) { - this.theme = theme; - - // update keyRgb from the current theme CSS itself, if it defines it - if (document.getElementById('mx_theme_accentColor')) { - this.keyRgb[0] = window.getComputedStyle( - document.getElementById('mx_theme_accentColor')).color; - } - if (document.getElementById('mx_theme_secondaryAccentColor')) { - this.keyRgb[1] = window.getComputedStyle( - document.getElementById('mx_theme_secondaryAccentColor')).color; - } - if (document.getElementById('mx_theme_tertiaryAccentColor')) { - this.keyRgb[2] = window.getComputedStyle( - document.getElementById('mx_theme_tertiaryAccentColor')).color; - } - - this.calcCssFixups(); - this.forceTint = true; - - this.tint(this.currentTint[0], this.currentTint[1], this.currentTint[2]); - - if (theme === 'dark') { - // abuse the tinter to change all the SVG's #fff to #2d2d2d - // XXX: obviously this shouldn't be hardcoded here. - this.tintSvgWhite('#2d2d2d'); - this.tintSvgBlack('#dddddd'); - } else { - this.tintSvgWhite('#ffffff'); - this.tintSvgBlack('#000000'); - } - } - - calcCssFixups() { - // cache our fixups - if (this.cssFixups[this.theme]) return; - - if (DEBUG) { - console.debug("calcCssFixups start for " + this.theme + " (checking " + - document.styleSheets.length + - " stylesheets)"); - } - - this.cssFixups[this.theme] = []; - - for (let i = 0; i < document.styleSheets.length; i++) { - const ss = document.styleSheets[i]; - try { - if (!ss) continue; // well done safari >:( - // Chromium apparently sometimes returns null here; unsure why. - // see $14534907369972FRXBx:matrix.org in HQ - // ...ah, it's because there's a third party extension like - // privacybadger inserting its own stylesheet in there with a - // resource:// URI or something which results in a XSS error. - // See also #vector:matrix.org/$145357669685386ebCfr:matrix.org - // ...except some browsers apparently return stylesheets without - // hrefs, which we have no choice but ignore right now - - // XXX seriously? we are hardcoding the name of vector's CSS file in - // here? - // - // Why do we need to limit it to vector's CSS file anyway - if there - // are other CSS files affecting the doc don't we want to apply the - // same transformations to them? - // - // Iterating through the CSS looking for matches to hack on feels - // pretty horrible anyway. And what if the application skin doesn't use - // Vector Green as its primary color? - // --richvdh - - // Yes, tinting assumes that you are using the Element skin for now. - // The right solution will be to move the CSS over to react-sdk. - // And yes, the default assets for the base skin might as well use - // Vector Green as any other colour. - // --matthew - - // stylesheets we don't have permission to access (eg. ones from extensions) have a null - // href and will throw exceptions if we try to access their rules. - if (!ss.href || !ss.href.match(new RegExp('/theme-' + this.theme + '.css$'))) continue; - if (ss.disabled) continue; - if (!ss.cssRules) continue; - - if (DEBUG) console.debug("calcCssFixups checking " + ss.cssRules.length + " rules for " + ss.href); - - for (let j = 0; j < ss.cssRules.length; j++) { - const rule = ss.cssRules[j]; - if (!rule.style) continue; - if (rule.selectorText && rule.selectorText.match(/#mx_theme/)) continue; - for (let k = 0; k < this.cssAttrs.length; k++) { - const attr = this.cssAttrs[k]; - for (let l = 0; l < this.keyRgb.length; l++) { - if (rule.style[attr] === this.keyRgb[l]) { - this.cssFixups[this.theme].push({ - style: rule.style, - attr: attr, - index: l, - }); - } - } - } - } - } catch (e) { - // Catch any random exceptions that happen here: all sorts of things can go - // wrong with this (nulls, SecurityErrors) and mostly it's for other - // stylesheets that we don't want to proces anyway. We should not propagate an - // exception out since this will cause the app to fail to start. - console.log("Failed to calculate CSS fixups for a stylesheet: " + ss.href, e); - } - } - if (DEBUG) { - console.log("calcCssFixups end (" + - this.cssFixups[this.theme].length + - " fixups)"); - } - } - - applyCssFixups() { - if (DEBUG) { - console.log("applyCssFixups start (" + - this.cssFixups[this.theme].length + - " fixups)"); - } - for (let i = 0; i < this.cssFixups[this.theme].length; i++) { - const cssFixup = this.cssFixups[this.theme][i]; - try { - cssFixup.style[cssFixup.attr] = this.colors[cssFixup.index]; - } catch (e) { - // Firefox Quantum explodes if you manually edit the CSS in the - // inspector and then try to do a tint, as apparently all the - // fixups are then stale. - console.error("Failed to apply cssFixup in Tinter! ", e.name); - } - } - if (DEBUG) console.log("applyCssFixups end"); - } - - // XXX: we could just move this all into TintableSvg, but as it's so similar - // to the CSS fixup stuff in Tinter (just that the fixups are stored in TintableSvg) - // keeping it here for now. - calcSvgFixups(svgs) { - // go through manually fixing up SVG colours. - // we could do this by stylesheets, but keeping the stylesheets - // updated would be a PITA, so just brute-force search for the - // key colour; cache the element and apply. - - if (DEBUG) console.log("calcSvgFixups start for " + svgs); - const fixups = []; - for (let i = 0; i < svgs.length; i++) { - let svgDoc; - try { - svgDoc = svgs[i].contentDocument; - } catch (e) { - let msg = 'Failed to get svg.contentDocument of ' + svgs[i].toString(); - if (e.message) { - msg += e.message; - } - if (e.stack) { - msg += ' | stack: ' + e.stack; - } - console.error(msg); - } - if (!svgDoc) continue; - const tags = svgDoc.getElementsByTagName("*"); - for (let j = 0; j < tags.length; j++) { - const tag = tags[j]; - for (let k = 0; k < this.svgAttrs.length; k++) { - const attr = this.svgAttrs[k]; - for (let l = 0; l < this.keyHex.length; l++) { - if (tag.getAttribute(attr) && - tag.getAttribute(attr).toUpperCase() === this.keyHex[l]) { - fixups.push({ - node: tag, - attr: attr, - index: l, - }); - } - } - } - } - } - if (DEBUG) console.log("calcSvgFixups end"); - - return fixups; - } - - applySvgFixups(fixups) { - if (DEBUG) console.log("applySvgFixups start for " + fixups); - for (let i = 0; i < fixups.length; i++) { - const svgFixup = fixups[i]; - svgFixup.node.setAttribute(svgFixup.attr, this.colors[svgFixup.index]); - } - if (DEBUG) console.log("applySvgFixups end"); - } -} - -if (global.singletonTinter === undefined) { - global.singletonTinter = new Tinter(); -} -export default global.singletonTinter; diff --git a/src/Unread.js b/src/Unread.ts similarity index 72% rename from src/Unread.js rename to src/Unread.ts index 25c425aa9a..da5b883f92 100644 --- a/src/Unread.js +++ b/src/Unread.ts @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +Copyright 2015 - 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. @@ -14,9 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from "./MatrixClientPeg"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { EventType } from "matrix-js-sdk/src/@types/event"; + +import { MatrixClientPeg } from "./MatrixClientPeg"; import shouldHideEvent from './shouldHideEvent'; -import {haveTileForEvent} from "./components/views/rooms/EventTile"; +import { haveTileForEvent } from "./components/views/rooms/EventTile"; /** * Returns true iff this event arriving in a room should affect the room's @@ -25,28 +29,27 @@ import {haveTileForEvent} from "./components/views/rooms/EventTile"; * @param {Object} ev The event * @returns {boolean} True if the given event should affect the unread message count */ -export function eventTriggersUnreadCount(ev) { - if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) { - return false; - } else if (ev.getType() == 'm.room.member') { - return false; - } else if (ev.getType() == 'm.room.third_party_invite') { - return false; - } else if (ev.getType() == 'm.call.answer' || ev.getType() == 'm.call.hangup') { - return false; - } else if (ev.getType() == 'm.room.message' && ev.getContent().msgtype == 'm.notify') { - return false; - } else if (ev.getType() == 'm.room.aliases' || ev.getType() == 'm.room.canonical_alias') { - return false; - } else if (ev.getType() == 'm.room.server_acl') { - return false; - } else if (ev.isRedacted()) { +export function eventTriggersUnreadCount(ev: MatrixEvent): boolean { + if (ev.getSender() === MatrixClientPeg.get().credentials.userId) { return false; } + + switch (ev.getType()) { + case EventType.RoomMember: + case EventType.RoomThirdPartyInvite: + case EventType.CallAnswer: + case EventType.CallHangup: + case EventType.RoomAliases: + case EventType.RoomCanonicalAlias: + case EventType.RoomServerAcl: + return false; + } + + if (ev.isRedacted()) return false; return haveTileForEvent(ev); } -export function doesRoomHaveUnreadMessages(room) { +export function doesRoomHaveUnreadMessages(room: Room): boolean { const myUserId = MatrixClientPeg.get().getUserId(); // get the most recent read receipt sent by our account. @@ -60,9 +63,7 @@ export function doesRoomHaveUnreadMessages(room) { // https://github.com/vector-im/element-web/issues/2427 // ...and possibly some of the others at // https://github.com/vector-im/element-web/issues/3363 - if (room.timeline.length && - room.timeline[room.timeline.length - 1].sender && - room.timeline[room.timeline.length - 1].sender.userId === myUserId) { + if (room.timeline.length && room.timeline[room.timeline.length - 1].getSender() === myUserId) { return false; } diff --git a/src/UserActivity.ts b/src/UserActivity.ts index 606075ec7c..c35ced0cc4 100644 --- a/src/UserActivity.ts +++ b/src/UserActivity.ts @@ -191,10 +191,10 @@ export default class UserActivity { this.lastScreenY = event.screenY; } - dis.dispatch({action: 'user_activity'}); + dis.dispatch({ action: 'user_activity' }); if (!this.activeNowTimeout.isRunning()) { this.activeNowTimeout.start(); - dis.dispatch({action: 'user_activity_start'}); + dis.dispatch({ action: 'user_activity_start' }); UserActivity.runTimersUntilTimeout(this.attachedActiveNowTimers, this.activeNowTimeout); } else { diff --git a/src/UserAddress.js b/src/UserAddress.ts similarity index 57% rename from src/UserAddress.js rename to src/UserAddress.ts index e7501a0d91..248814aa01 100644 --- a/src/UserAddress.js +++ b/src/UserAddress.ts @@ -1,5 +1,5 @@ /* -Copyright 2017 New Vector Ltd +Copyright 2017 - 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. @@ -15,43 +15,40 @@ limitations under the License. */ const emailRegex = /^\S+@\S+\.\S+$/; - const mxUserIdRegex = /^@\S+:\S+$/; const mxRoomIdRegex = /^!\S+:\S+$/; -import PropTypes from 'prop-types'; -export const addressTypes = [ - 'mx-user-id', 'mx-room-id', 'email', -]; +export enum AddressType { + Email = "email", + MatrixUserId = "mx-user-id", + MatrixRoomId = "mx-room-id", +} + +export const addressTypes = [AddressType.Email, AddressType.MatrixRoomId, AddressType.MatrixUserId]; // PropType definition for an object describing // an address that can be invited to a room (which // could be a third party identifier or a matrix ID) // along with some additional information about the // address / target. -export const UserAddressType = PropTypes.shape({ - addressType: PropTypes.oneOf(addressTypes).isRequired, - address: PropTypes.string.isRequired, - displayName: PropTypes.string, - avatarMxc: PropTypes.string, +export interface IUserAddress { + addressType: AddressType; + address: string; + displayName?: string; + avatarMxc?: string; // true if the address is known to be a valid address (eg. is a real // user we've seen) or false otherwise (eg. is just an address the // user has entered) - isKnown: PropTypes.bool, -}); + isKnown?: boolean; +} -export function getAddressType(inputText) { - const isEmailAddress = emailRegex.test(inputText); - const isUserId = mxUserIdRegex.test(inputText); - const isRoomId = mxRoomIdRegex.test(inputText); - - // sanity check the input for user IDs - if (isEmailAddress) { - return 'email'; - } else if (isUserId) { - return 'mx-user-id'; - } else if (isRoomId) { - return 'mx-room-id'; +export function getAddressType(inputText: string): AddressType | null { + if (emailRegex.test(inputText)) { + return AddressType.Email; + } else if (mxUserIdRegex.test(inputText)) { + return AddressType.MatrixUserId; + } else if (mxRoomIdRegex.test(inputText)) { + return AddressType.MatrixRoomId; } else { return null; } diff --git a/src/VoipUserMapper.ts b/src/VoipUserMapper.ts index d576a5434c..e2e590548e 100644 --- a/src/VoipUserMapper.ts +++ b/src/VoipUserMapper.ts @@ -20,11 +20,15 @@ import DMRoomMap from "./utils/DMRoomMap"; import CallHandler, { VIRTUAL_ROOM_EVENT_TYPE } from './CallHandler'; import { Room } from 'matrix-js-sdk/src/models/room'; +import { logger } from "matrix-js-sdk/src/logger"; + // Functions for mapping virtual users & rooms. Currently the only lookup // is sip virtual: there could be others in the future. export default class VoipUserMapper { - private virtualRoomIdCache = new Set(); + // We store mappings of virtual -> native room IDs here until the local echo for the + // account data arrives. + private virtualToNativeRoomIdCache = new Map(); public static sharedInstance(): VoipUserMapper { if (window.mxVoipUserMapper === undefined) window.mxVoipUserMapper = new VoipUserMapper(); @@ -49,10 +53,20 @@ export default class VoipUserMapper { native_room: roomId, }); + this.virtualToNativeRoomIdCache.set(virtualRoomId, roomId); + return virtualRoomId; } public nativeRoomForVirtualRoom(roomId: string): string { + const cachedNativeRoomId = this.virtualToNativeRoomIdCache.get(roomId); + if (cachedNativeRoomId) { + logger.log( + "Returning native room ID " + cachedNativeRoomId + " for virtual room ID " + roomId + " from cache", + ); + return cachedNativeRoomId; + } + const virtualRoom = MatrixClientPeg.get().getRoom(roomId); if (!virtualRoom) return null; const virtualRoomEvent = virtualRoom.getAccountData(VIRTUAL_ROOM_EVENT_TYPE); @@ -67,7 +81,7 @@ export default class VoipUserMapper { public isVirtualRoom(room: Room): boolean { if (this.nativeRoomForVirtualRoom(room.roomId)) return true; - if (this.virtualRoomIdCache.has(room.roomId)) return true; + if (this.virtualToNativeRoomIdCache.has(room.roomId)) return true; // also look in the create event for the claimed native room ID, which is the only // way we can recognise a virtual room we've created when it first arrives down @@ -86,7 +100,7 @@ export default class VoipUserMapper { if (!CallHandler.sharedInstance().getSupportsVirtualRooms()) return; const inviterId = invitedRoom.getDMInviter(); - console.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`); + logger.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`); const result = await CallHandler.sharedInstance().sipNativeLookup(inviterId); if (result.length === 0) { return; @@ -110,7 +124,7 @@ export default class VoipUserMapper { // also put this room in the virtual room ID cache so isVirtualRoom return the right answer // in however long it takes for the echo of setAccountData to come down the sync - this.virtualRoomIdCache.add(invitedRoom.roomId); + this.virtualToNativeRoomIdCache.set(invitedRoom.roomId, nativeRoom.roomId); } } } diff --git a/src/WhoIsTyping.ts b/src/WhoIsTyping.ts index a8ca425ea8..938218d270 100644 --- a/src/WhoIsTyping.ts +++ b/src/WhoIsTyping.ts @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {Room} from "matrix-js-sdk/src/models/room"; -import {RoomMember} from "matrix-js-sdk/src/models/room-member"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; -import {MatrixClientPeg} from "./MatrixClientPeg"; +import { MatrixClientPeg } from "./MatrixClientPeg"; import { _t } from './languageHandler'; export function usersTypingApartFromMeAndIgnored(room: Room): RoomMember[] { @@ -61,7 +61,7 @@ export function whoIsTypingString(whoIsTyping: RoomMember[], limit: number): str if (whoIsTyping.length === 0) { return ''; } else if (whoIsTyping.length === 1) { - return _t('%(displayName)s is typing …', {displayName: whoIsTyping[0].name}); + return _t('%(displayName)s is typing …', { displayName: whoIsTyping[0].name }); } const names = whoIsTyping.map(m => m.name); @@ -73,6 +73,6 @@ export function whoIsTypingString(whoIsTyping: RoomMember[], limit: number): str }); } else { const lastPerson = names.pop(); - return _t('%(names)s and %(lastPerson)s are typing …', {names: names.join(', '), lastPerson: lastPerson}); + return _t('%(names)s and %(lastPerson)s are typing …', { names: names.join(', '), lastPerson: lastPerson }); } } diff --git a/src/accessibility/KeyboardShortcuts.tsx b/src/accessibility/KeyboardShortcuts.tsx index 2a3e576e31..c66984191f 100644 --- a/src/accessibility/KeyboardShortcuts.tsx +++ b/src/accessibility/KeyboardShortcuts.tsx @@ -17,10 +17,10 @@ limitations under the License. import * as React from "react"; import classNames from "classnames"; -import * as sdk from "../index"; import Modal from "../Modal"; import { _t, _td } from "../languageHandler"; -import {isMac, Key} from "../Keyboard"; +import { isMac, Key } from "../Keyboard"; +import InfoDialog from "../components/views/dialogs/InfoDialog"; // TS: once languageHandler is TS we can probably inline this into the enum _td("Navigation"); @@ -57,6 +57,8 @@ export enum Modifiers { // Meta-modifier: isMac ? CMD : CONTROL export const CMD_OR_CTRL = isMac ? Modifiers.COMMAND : Modifiers.CONTROL; +// Meta-key representing the digits [0-9] often found at the top of standard keyboard layouts +export const DIGITS = "digits"; interface IKeybind { modifiers?: Modifiers[]; @@ -161,7 +163,7 @@ const shortcuts: Record = { modifiers: [Modifiers.SHIFT], key: Key.PAGE_UP, }], - description: _td("Jump to oldest unread message"), + description: _td("Jump to oldest unread message"), }, { keybinds: [{ modifiers: [CMD_OR_CTRL, Modifiers.SHIFT], @@ -319,6 +321,7 @@ const alternateKeyName: Record = { [Key.SPACE]: _td("Space"), [Key.HOME]: _td("Home"), [Key.END]: _td("End"), + [DIGITS]: _td("[number]"), }; const keyIcon: Record = { [Key.ARROW_UP]: "↑", @@ -329,7 +332,7 @@ const keyIcon: Record = { const Shortcut: React.FC<{ shortcut: IShortcut; -}> = ({shortcut}) => { +}> = ({ shortcut }) => { const classes = classNames({ "mx_KeyboardShortcutsDialog_inline": shortcut.keybinds.every(k => !k.modifiers || k.modifiers.length === 0), }); @@ -367,12 +370,11 @@ export const toggleDialog = () => { const sections = categoryOrder.map(category => { const list = shortcuts[category]; return
-

{_t(category)}

-
{list.map(shortcut => )}
+

{ _t(category) }

+
{ list.map(shortcut => ) }
; }); - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); activeModal = Modal.createTrackedDialog("Keyboard Shortcuts", "", InfoDialog, { className: "mx_KeyboardShortcutsDialog", title: _t("Keyboard Shortcuts"), diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index 4cb537f318..68e10049fd 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -26,8 +26,8 @@ import React, { Dispatch, } from "react"; -import {Key} from "../Keyboard"; -import {FocusHandler, Ref} from "./roving/types"; +import { Key } from "../Keyboard"; +import { FocusHandler, Ref } from "./roving/types"; /** * Module to simplify implementing the Roving TabIndex accessibility technique @@ -150,38 +150,68 @@ const reducer = (state: IState, action: IAction) => { interface IProps { handleHomeEnd?: boolean; + handleUpDown?: boolean; children(renderProps: { onKeyDownHandler(ev: React.KeyboardEvent); }); onKeyDown?(ev: React.KeyboardEvent, state: IState); } -export const RovingTabIndexProvider: React.FC = ({children, handleHomeEnd, onKeyDown}) => { +export const RovingTabIndexProvider: React.FC = ({ children, handleHomeEnd, handleUpDown, onKeyDown }) => { const [state, dispatch] = useReducer>(reducer, { activeRef: null, refs: [], }); - const context = useMemo(() => ({state, dispatch}), [state]); + const context = useMemo(() => ({ state, dispatch }), [state]); const onKeyDownHandler = useCallback((ev) => { let handled = false; // Don't interfere with input default keydown behaviour - if (handleHomeEnd && ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") { + if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") { // check if we actually have any items switch (ev.key) { case Key.HOME: - handled = true; - // move focus to first item - if (context.state.refs.length > 0) { - context.state.refs[0].current.focus(); + if (handleHomeEnd) { + handled = true; + // move focus to first item + if (context.state.refs.length > 0) { + context.state.refs[0].current.focus(); + } } break; + case Key.END: - handled = true; - // move focus to last item - if (context.state.refs.length > 0) { - context.state.refs[context.state.refs.length - 1].current.focus(); + if (handleHomeEnd) { + handled = true; + // move focus to last item + if (context.state.refs.length > 0) { + context.state.refs[context.state.refs.length - 1].current.focus(); + } + } + break; + + case Key.ARROW_UP: + if (handleUpDown) { + handled = true; + if (context.state.refs.length > 0) { + const idx = context.state.refs.indexOf(context.state.activeRef); + if (idx > 0) { + context.state.refs[idx - 1].current.focus(); + } + } + } + break; + + case Key.ARROW_DOWN: + if (handleUpDown) { + handled = true; + if (context.state.refs.length > 0) { + const idx = context.state.refs.indexOf(context.state.activeRef); + if (idx < context.state.refs.length - 1) { + context.state.refs[idx + 1].current.focus(); + } + } } break; } @@ -193,10 +223,10 @@ export const RovingTabIndexProvider: React.FC = ({children, handleHomeEn } else if (onKeyDown) { return onKeyDown(ev, context.state); } - }, [context.state, onKeyDown, handleHomeEnd]); + }, [context.state, onKeyDown, handleHomeEnd, handleUpDown]); return - { children({onKeyDownHandler}) } + { children({ onKeyDownHandler }) } ; }; @@ -218,13 +248,13 @@ export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref] useLayoutEffect(() => { context.dispatch({ type: Type.Register, - payload: {ref}, + payload: { ref }, }); // teardown return () => { context.dispatch({ type: Type.Unregister, - payload: {ref}, + payload: { ref }, }); }; }, []); // eslint-disable-line react-hooks/exhaustive-deps @@ -232,7 +262,7 @@ export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref] const onFocus = useCallback(() => { context.dispatch({ type: Type.SetFocus, - payload: {ref}, + payload: { ref }, }); }, [ref, context]); @@ -241,6 +271,6 @@ export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref] }; // re-export the semantic helper components for simplicity -export {RovingTabIndexWrapper} from "./roving/RovingTabIndexWrapper"; -export {RovingAccessibleButton} from "./roving/RovingAccessibleButton"; -export {RovingAccessibleTooltipButton} from "./roving/RovingAccessibleTooltipButton"; +export { RovingTabIndexWrapper } from "./roving/RovingTabIndexWrapper"; +export { RovingAccessibleButton } from "./roving/RovingAccessibleButton"; +export { RovingAccessibleTooltipButton } from "./roving/RovingAccessibleTooltipButton"; diff --git a/src/accessibility/Toolbar.tsx b/src/accessibility/Toolbar.tsx index e756d948e5..90538760bb 100644 --- a/src/accessibility/Toolbar.tsx +++ b/src/accessibility/Toolbar.tsx @@ -16,8 +16,8 @@ limitations under the License. import React from "react"; -import {IState, RovingTabIndexProvider} from "./RovingTabIndex"; -import {Key} from "../Keyboard"; +import { IState, RovingTabIndexProvider } from "./RovingTabIndex"; +import { Key } from "../Keyboard"; interface IProps extends Omit, "onKeyDown"> { } @@ -25,7 +25,7 @@ interface IProps extends Omit, "onKeyDown"> { // This component implements the Toolbar design pattern from the WAI-ARIA Authoring Practices guidelines. // https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar // All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref` -const Toolbar: React.FC = ({children, ...props}) => { +const Toolbar: React.FC = ({ children, ...props }) => { const onKeyDown = (ev: React.KeyboardEvent, state: IState) => { const target = ev.target as HTMLElement; // Don't interfere with input default keydown behaviour @@ -62,9 +62,9 @@ const Toolbar: React.FC = ({children, ...props}) => { }; return - {({onKeyDownHandler}) =>
+ { ({ onKeyDownHandler }) =>
{ children } -
} +
}
; }; diff --git a/src/accessibility/context_menu/MenuGroup.tsx b/src/accessibility/context_menu/MenuGroup.tsx index 9334e17a18..97f9694f83 100644 --- a/src/accessibility/context_menu/MenuGroup.tsx +++ b/src/accessibility/context_menu/MenuGroup.tsx @@ -23,7 +23,7 @@ interface IProps extends React.HTMLAttributes { } // Semantic component for representing a role=group for grouping menu radios/checkboxes -export const MenuGroup: React.FC = ({children, label, ...props}) => { +export const MenuGroup: React.FC = ({ children, label, ...props }) => { return
{ children }
; diff --git a/src/accessibility/context_menu/MenuItem.tsx b/src/accessibility/context_menu/MenuItem.tsx index 9a7c1d1f0a..9c0b248274 100644 --- a/src/accessibility/context_menu/MenuItem.tsx +++ b/src/accessibility/context_menu/MenuItem.tsx @@ -27,7 +27,7 @@ interface IProps extends React.ComponentProps { } // Semantic component for representing a role=menuitem -export const MenuItem: React.FC = ({children, label, tooltip, ...props}) => { +export const MenuItem: React.FC = ({ children, label, tooltip, ...props }) => { const ariaLabel = props["aria-label"] || label; if (tooltip) { diff --git a/src/accessibility/context_menu/MenuItemCheckbox.tsx b/src/accessibility/context_menu/MenuItemCheckbox.tsx index 5eb8cc4819..67da4cc85a 100644 --- a/src/accessibility/context_menu/MenuItemCheckbox.tsx +++ b/src/accessibility/context_menu/MenuItemCheckbox.tsx @@ -26,7 +26,7 @@ interface IProps extends React.ComponentProps { } // Semantic component for representing a role=menuitemcheckbox -export const MenuItemCheckbox: React.FC = ({children, label, active, disabled, ...props}) => { +export const MenuItemCheckbox: React.FC = ({ children, label, active, disabled, ...props }) => { return ( { } // Semantic component for representing a role=menuitemradio -export const MenuItemRadio: React.FC = ({children, label, active, disabled, ...props}) => { +export const MenuItemRadio: React.FC = ({ children, label, active, disabled, ...props }) => { return ( { @@ -28,7 +28,7 @@ interface IProps extends React.ComponentProps { } // Semantic component for representing a styled role=menuitemcheckbox -export const StyledMenuItemCheckbox: React.FC = ({children, label, onChange, onClose, ...props}) => { +export const StyledMenuItemCheckbox: React.FC = ({ children, label, onChange, onClose, ...props }) => { const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === Key.ENTER || e.key === Key.SPACE) { e.stopPropagation(); diff --git a/src/accessibility/context_menu/StyledMenuItemRadio.tsx b/src/accessibility/context_menu/StyledMenuItemRadio.tsx index 5e5aa90a38..e3d340ef3e 100644 --- a/src/accessibility/context_menu/StyledMenuItemRadio.tsx +++ b/src/accessibility/context_menu/StyledMenuItemRadio.tsx @@ -18,7 +18,7 @@ limitations under the License. import React from "react"; -import {Key} from "../../Keyboard"; +import { Key } from "../../Keyboard"; import StyledRadioButton from "../../components/views/elements/StyledRadioButton"; interface IProps extends React.ComponentProps { @@ -28,7 +28,7 @@ interface IProps extends React.ComponentProps { } // Semantic component for representing a styled role=menuitemradio -export const StyledMenuItemRadio: React.FC = ({children, label, onChange, onClose, ...props}) => { +export const StyledMenuItemRadio: React.FC = ({ children, label, onChange, onClose, ...props }) => { const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === Key.ENTER || e.key === Key.SPACE) { e.stopPropagation(); diff --git a/src/accessibility/roving/RovingAccessibleButton.tsx b/src/accessibility/roving/RovingAccessibleButton.tsx index 3473ef1bc9..f9ce87db6a 100644 --- a/src/accessibility/roving/RovingAccessibleButton.tsx +++ b/src/accessibility/roving/RovingAccessibleButton.tsx @@ -17,15 +17,15 @@ limitations under the License. import React from "react"; import AccessibleButton from "../../components/views/elements/AccessibleButton"; -import {useRovingTabIndex} from "../RovingTabIndex"; -import {Ref} from "./types"; +import { useRovingTabIndex } from "../RovingTabIndex"; +import { Ref } from "./types"; interface IProps extends Omit, "onFocus" | "inputRef" | "tabIndex"> { inputRef?: Ref; } // Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components. -export const RovingAccessibleButton: React.FC = ({inputRef, ...props}) => { +export const RovingAccessibleButton: React.FC = ({ inputRef, ...props }) => { const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); return ; }; diff --git a/src/accessibility/roving/RovingAccessibleTooltipButton.tsx b/src/accessibility/roving/RovingAccessibleTooltipButton.tsx index 2cb974d60e..d9e393d728 100644 --- a/src/accessibility/roving/RovingAccessibleTooltipButton.tsx +++ b/src/accessibility/roving/RovingAccessibleTooltipButton.tsx @@ -17,8 +17,8 @@ limitations under the License. import React from "react"; import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton"; -import {useRovingTabIndex} from "../RovingTabIndex"; -import {Ref} from "./types"; +import { useRovingTabIndex } from "../RovingTabIndex"; +import { Ref } from "./types"; type ATBProps = React.ComponentProps; interface IProps extends Omit { @@ -26,7 +26,7 @@ interface IProps extends Omit { } // Wrapper to allow use of useRovingTabIndex for simple AccessibleTooltipButtons outside of React Functional Components. -export const RovingAccessibleTooltipButton: React.FC = ({inputRef, ...props}) => { +export const RovingAccessibleTooltipButton: React.FC = ({ inputRef, ...props }) => { const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); return ; }; diff --git a/src/accessibility/roving/RovingTabIndexWrapper.tsx b/src/accessibility/roving/RovingTabIndexWrapper.tsx index 5211f30215..974bb9a388 100644 --- a/src/accessibility/roving/RovingTabIndexWrapper.tsx +++ b/src/accessibility/roving/RovingTabIndexWrapper.tsx @@ -16,8 +16,8 @@ limitations under the License. import React from "react"; -import {useRovingTabIndex} from "../RovingTabIndex"; -import {FocusHandler, Ref} from "./types"; +import { useRovingTabIndex } from "../RovingTabIndex"; +import { FocusHandler, Ref } from "./types"; interface IProps { inputRef?: Ref; @@ -29,7 +29,7 @@ interface IProps { } // Wrapper to allow use of useRovingTabIndex outside of React Functional Components. -export const RovingTabIndexWrapper: React.FC = ({children, inputRef}) => { +export const RovingTabIndexWrapper: React.FC = ({ children, inputRef }) => { const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); - return children({onFocus, isActive, ref}); + return children({ onFocus, isActive, ref }); }; diff --git a/src/accessibility/roving/types.ts b/src/accessibility/roving/types.ts index f0a43e5fb8..cc6a98e1d7 100644 --- a/src/accessibility/roving/types.ts +++ b/src/accessibility/roving/types.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {RefObject} from "react"; +import { RefObject } from "react"; export type Ref = RefObject; diff --git a/src/actions/MatrixActionCreators.js b/src/actions/MatrixActionCreators.ts similarity index 68% rename from src/actions/MatrixActionCreators.js rename to src/actions/MatrixActionCreators.ts index 93a4fcf07c..70b86f8ee5 100644 --- a/src/actions/MatrixActionCreators.js +++ b/src/actions/MatrixActionCreators.ts @@ -1,5 +1,5 @@ /* -Copyright 2017 New Vector Ltd +Copyright 2017, 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. @@ -14,7 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import dis from '../dispatcher/dispatcher'; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { EventTimeline } from "matrix-js-sdk/src/models/event-timeline"; + +import dis from "../dispatcher/dispatcher"; +import { ActionPayload } from "../dispatcher/payloads"; // TODO: migrate from sync_state to MatrixActions.sync so that more js-sdk events // become dispatches in the same place. @@ -27,7 +33,7 @@ import dis from '../dispatcher/dispatcher'; * @param {string} prevState the previous sync state. * @returns {Object} an action of type MatrixActions.sync. */ -function createSyncAction(matrixClient, state, prevState) { +function createSyncAction(matrixClient: MatrixClient, state: string, prevState: string): ActionPayload { return { action: 'MatrixActions.sync', state, @@ -53,7 +59,7 @@ function createSyncAction(matrixClient, state, prevState) { * @param {MatrixEvent} accountDataEvent the account data event. * @returns {AccountDataAction} an action of type MatrixActions.accountData. */ -function createAccountDataAction(matrixClient, accountDataEvent) { +function createAccountDataAction(matrixClient: MatrixClient, accountDataEvent: MatrixEvent): ActionPayload { return { action: 'MatrixActions.accountData', event: accountDataEvent, @@ -81,7 +87,11 @@ function createAccountDataAction(matrixClient, accountDataEvent) { * @param {Room} room the room where account data was changed * @returns {RoomAccountDataAction} an action of type MatrixActions.Room.accountData. */ -function createRoomAccountDataAction(matrixClient, accountDataEvent, room) { +function createRoomAccountDataAction( + matrixClient: MatrixClient, + accountDataEvent: MatrixEvent, + room: Room, +): ActionPayload { return { action: 'MatrixActions.Room.accountData', event: accountDataEvent, @@ -106,7 +116,7 @@ function createRoomAccountDataAction(matrixClient, accountDataEvent, room) { * @param {Room} room the Room that was stored. * @returns {RoomAction} an action of type `MatrixActions.Room`. */ -function createRoomAction(matrixClient, room) { +function createRoomAction(matrixClient: MatrixClient, room: Room): ActionPayload { return { action: 'MatrixActions.Room', room }; } @@ -127,7 +137,7 @@ function createRoomAction(matrixClient, room) { * @param {Room} room the Room whose tags were changed. * @returns {RoomTagsAction} an action of type `MatrixActions.Room.tags`. */ -function createRoomTagsAction(matrixClient, roomTagsEvent, room) { +function createRoomTagsAction(matrixClient: MatrixClient, roomTagsEvent: MatrixEvent, room: Room): ActionPayload { return { action: 'MatrixActions.Room.tags', room }; } @@ -140,7 +150,7 @@ function createRoomTagsAction(matrixClient, roomTagsEvent, room) { * @param {Room} room the room the receipt happened in. * @returns {Object} an action of type MatrixActions.Room.receipt. */ -function createRoomReceiptAction(matrixClient, event, room) { +function createRoomReceiptAction(matrixClient: MatrixClient, event: MatrixEvent, room: Room): ActionPayload { return { action: 'MatrixActions.Room.receipt', event, @@ -178,7 +188,17 @@ function createRoomReceiptAction(matrixClient, event, room) { * @param {EventTimeline} data.timeline the timeline being altered. * @returns {RoomTimelineAction} an action of type `MatrixActions.Room.timeline`. */ -function createRoomTimelineAction(matrixClient, timelineEvent, room, toStartOfTimeline, removed, data) { +function createRoomTimelineAction( + matrixClient: MatrixClient, + timelineEvent: MatrixEvent, + room: Room, + toStartOfTimeline: boolean, + removed: boolean, + data: { + liveEvent: boolean; + timeline: EventTimeline; + }, +): ActionPayload { return { action: 'MatrixActions.Room.timeline', event: timelineEvent, @@ -208,8 +228,13 @@ function createRoomTimelineAction(matrixClient, timelineEvent, room, toStartOfTi * @param {string} oldMembership the previous membership, can be null. * @returns {RoomMembershipAction} an action of type `MatrixActions.Room.myMembership`. */ -function createSelfMembershipAction(matrixClient, room, membership, oldMembership) { - return { action: 'MatrixActions.Room.myMembership', room, membership, oldMembership}; +function createSelfMembershipAction( + matrixClient: MatrixClient, + room: Room, + membership: string, + oldMembership: string, +): ActionPayload { + return { action: 'MatrixActions.Room.myMembership', room, membership, oldMembership }; } /** @@ -228,61 +253,65 @@ function createSelfMembershipAction(matrixClient, room, membership, oldMembershi * @param {MatrixEvent} event the matrix event that was decrypted. * @returns {EventDecryptedAction} an action of type `MatrixActions.Event.decrypted`. */ -function createEventDecryptedAction(matrixClient, event) { +function createEventDecryptedAction(matrixClient: MatrixClient, event: MatrixEvent): ActionPayload { return { action: 'MatrixActions.Event.decrypted', event }; } +type Listener = () => void; +type ActionCreator = (matrixClient: MatrixClient, ...args: any) => ActionPayload; + +// A list of callbacks to call to unregister all listeners added +let matrixClientListenersStop: Listener[] = []; + +/** + * Start listening to events of type eventName on matrixClient and when they are emitted, + * dispatch an action created by the actionCreator function. + * @param {MatrixClient} matrixClient a MatrixClient to register a listener with. + * @param {string} eventName the event to listen to on MatrixClient. + * @param {function} actionCreator a function that should return an action to dispatch + * when given the MatrixClient as an argument as well as + * arguments emitted in the MatrixClient event. + */ +function addMatrixClientListener(matrixClient: MatrixClient, eventName: string, actionCreator: ActionCreator): void { + const listener: Listener = (...args) => { + const payload = actionCreator(matrixClient, ...args); + if (payload) { + dis.dispatch(payload, true); + } + }; + matrixClient.on(eventName, listener); + matrixClientListenersStop.push(() => { + matrixClient.removeListener(eventName, listener); + }); +} + /** * This object is responsible for dispatching actions when certain events are emitted by * the given MatrixClient. */ export default { - // A list of callbacks to call to unregister all listeners added - _matrixClientListenersStop: [], - /** * Start listening to certain events from the MatrixClient and dispatch actions when * they are emitted. * @param {MatrixClient} matrixClient the MatrixClient to listen to events from */ - start(matrixClient) { - this._addMatrixClientListener(matrixClient, 'sync', createSyncAction); - this._addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction); - this._addMatrixClientListener(matrixClient, 'Room.accountData', createRoomAccountDataAction); - this._addMatrixClientListener(matrixClient, 'Room', createRoomAction); - this._addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction); - this._addMatrixClientListener(matrixClient, 'Room.receipt', createRoomReceiptAction); - this._addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction); - this._addMatrixClientListener(matrixClient, 'Room.myMembership', createSelfMembershipAction); - this._addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction); - }, - - /** - * Start listening to events of type eventName on matrixClient and when they are emitted, - * dispatch an action created by the actionCreator function. - * @param {MatrixClient} matrixClient a MatrixClient to register a listener with. - * @param {string} eventName the event to listen to on MatrixClient. - * @param {function} actionCreator a function that should return an action to dispatch - * when given the MatrixClient as an argument as well as - * arguments emitted in the MatrixClient event. - */ - _addMatrixClientListener(matrixClient, eventName, actionCreator) { - const listener = (...args) => { - const payload = actionCreator(matrixClient, ...args); - if (payload) { - dis.dispatch(payload, true); - } - }; - matrixClient.on(eventName, listener); - this._matrixClientListenersStop.push(() => { - matrixClient.removeListener(eventName, listener); - }); + start(matrixClient: MatrixClient) { + addMatrixClientListener(matrixClient, 'sync', createSyncAction); + addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction); + addMatrixClientListener(matrixClient, 'Room.accountData', createRoomAccountDataAction); + addMatrixClientListener(matrixClient, 'Room', createRoomAction); + addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction); + addMatrixClientListener(matrixClient, 'Room.receipt', createRoomReceiptAction); + addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction); + addMatrixClientListener(matrixClient, 'Room.myMembership', createSelfMembershipAction); + addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction); }, /** * Stop listening to events. */ stop() { - this._matrixClientListenersStop.forEach((stopListener) => stopListener()); + matrixClientListenersStop.forEach((stopListener) => stopListener()); + matrixClientListenersStop = []; }, }; diff --git a/src/actions/RoomListActions.ts b/src/actions/RoomListActions.ts index 88946ee26f..a7f629c40d 100644 --- a/src/actions/RoomListActions.ts +++ b/src/actions/RoomListActions.ts @@ -19,13 +19,13 @@ import { asyncAction } from './actionCreators'; import Modal from '../Modal'; import * as Rooms from '../Rooms'; import { _t } from '../languageHandler'; -import * as sdk from '../index'; import { MatrixClient } from "matrix-js-sdk/src/client"; import { Room } from "matrix-js-sdk/src/models/room"; import { AsyncActionPayload } from "../dispatcher/payloads"; import RoomListStore from "../stores/room-list/RoomListStore"; import { SortAlgorithm } from "../stores/room-list/algorithms/models"; import { DefaultTagID } from "../stores/room-list/models"; +import ErrorDialog from '../components/views/dialogs/ErrorDialog'; export default class RoomListActions { /** @@ -88,7 +88,6 @@ export default class RoomListActions { return Rooms.guessAndSetDMRoom( room, newTag === DefaultTagID.DM, ).catch((err) => { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to set direct chat tag " + err); Modal.createTrackedDialog('Failed to set direct chat tag', '', ErrorDialog, { title: _t('Failed to set direct chat tag'), @@ -109,10 +108,9 @@ export default class RoomListActions { const promiseToDelete = matrixClient.deleteRoomTag( roomId, oldTag, ).catch(function(err) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to remove tag " + oldTag + " from room: " + err); Modal.createTrackedDialog('Failed to remove tag from room', '', ErrorDialog, { - title: _t('Failed to remove tag %(tagName)s from room', {tagName: oldTag}), + title: _t('Failed to remove tag %(tagName)s from room', { tagName: oldTag }), description: ((err && err.message) ? err.message : _t('Operation failed')), }); }); @@ -129,10 +127,9 @@ export default class RoomListActions { metaData = metaData || {}; const promiseToAdd = matrixClient.setRoomTag(roomId, newTag, metaData).catch(function(err) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to add tag " + newTag + " to room: " + err); Modal.createTrackedDialog('Failed to add tag to room', '', ErrorDialog, { - title: _t('Failed to add tag %(tagName)s to room', {tagName: newTag}), + title: _t('Failed to add tag %(tagName)s to room', { tagName: newTag }), description: ((err && err.message) ? err.message : _t('Operation failed')), }); diff --git a/src/actions/TagOrderActions.ts b/src/actions/TagOrderActions.ts index 021cd11b55..dc538134a1 100644 --- a/src/actions/TagOrderActions.ts +++ b/src/actions/TagOrderActions.ts @@ -53,11 +53,11 @@ export default class TagOrderActions { Analytics.trackEvent('TagOrderActions', 'commitTagOrdering'); return matrixClient.setAccountData( 'im.vector.web.tag_ordering', - {tags, removedTags, _storeId: storeId}, + { tags, removedTags, _storeId: storeId }, ); }, () => { // For an optimistic update - return {tags, removedTags}; + return { tags, removedTags }; }); } @@ -100,11 +100,11 @@ export default class TagOrderActions { Analytics.trackEvent('TagOrderActions', 'removeTag'); return matrixClient.setAccountData( 'im.vector.web.tag_ordering', - {tags, removedTags, _storeId: storeId}, + { tags, removedTags, _storeId: storeId }, ); }, () => { // For an optimistic update - return {removedTags}; + return { removedTags }; }); } } diff --git a/src/actions/actionCreators.ts b/src/actions/actionCreators.ts index c789e3cd07..81e0b95098 100644 --- a/src/actions/actionCreators.ts +++ b/src/actions/actionCreators.ts @@ -51,9 +51,9 @@ export function asyncAction(id: string, fn: () => Promise, pendingFn: () => request: typeof pendingFn === 'function' ? pendingFn() : undefined, }); fn().then((result) => { - dispatch({action: id + '.success', result}); + dispatch({ action: id + '.success', result }); }).catch((err) => { - dispatch({action: id + '.failure', err}); + dispatch({ action: id + '.failure', err }); }); }; return new AsyncActionPayload(helper); diff --git a/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js b/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.tsx similarity index 66% rename from src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js rename to src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.tsx index de50feaedb..4d8f5e5663 100644 --- a/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js +++ b/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.tsx @@ -15,55 +15,55 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../../index'; -import PropTypes from 'prop-types'; + +import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; +import Spinner from "../../../../components/views/elements/Spinner"; +import DialogButtons from "../../../../components/views/elements/DialogButtons"; import dis from "../../../../dispatcher/dispatcher"; import { _t } from '../../../../languageHandler'; import SettingsStore from "../../../../settings/SettingsStore"; import EventIndexPeg from "../../../../indexing/EventIndexPeg"; -import {Action} from "../../../../dispatcher/actions"; -import {SettingLevel} from "../../../../settings/SettingLevel"; +import { Action } from "../../../../dispatcher/actions"; +import { SettingLevel } from "../../../../settings/SettingLevel"; +interface IProps { + onFinished: (success: boolean) => void; +} + +interface IState { + disabling: boolean; +} /* * Allows the user to disable the Event Index. */ -export default class DisableEventIndexDialog extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - } - - constructor(props) { +export default class DisableEventIndexDialog extends React.Component { + constructor(props: IProps) { super(props); - this.state = { disabling: false, }; } - _onDisable = async () => { + private onDisable = async (): Promise => { this.setState({ disabling: true, }); await SettingsStore.setValue('enableEventIndexing', null, SettingLevel.DEVICE, false); await EventIndexPeg.deleteEventIndex(); - this.props.onFinished(); + this.props.onFinished(true); dis.fire(Action.ViewUserSettings); - } - - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const Spinner = sdk.getComponent('elements.Spinner'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + }; + public render(): React.ReactNode { return ( - {_t("If disabled, messages from encrypted rooms won't appear in search results.")} - {this.state.disabling ? :
} + { _t("If disabled, messages from encrypted rooms won't appear in search results.") } + { this.state.disabling ? :
} void; -} +interface IProps extends IDialogProps {} interface IState { eventIndexSize: number; @@ -132,20 +133,20 @@ export default class ManageEventIndexDialog extends React.Component { - Modal.createTrackedDialogAsync("Disable message search", "Disable message search", - import("./DisableEventIndexDialog"), + const DisableEventIndexDialog = (await import("./DisableEventIndexDialog")).default; + Modal.createTrackedDialog("Disable message search", "Disable message search", + DisableEventIndexDialog, null, null, /* priority = */ false, /* static = */ true, ); }; private onCrawlerSleepTimeChange = (e) => { - this.setState({crawlerSleepTime: e.target.value}); + this.setState({ crawlerSleepTime: e.target.value }); SettingsStore.setValue("crawlerSleepTime", null, SettingLevel.DEVICE, e.target.value); }; render() { const brand = SdkConfig.get().brand; - const Field = sdk.getComponent('views.elements.Field'); let crawlerState; if (this.state.currentRoom === null) { @@ -160,37 +161,34 @@ export default class ManageEventIndexDialog extends React.Component - {_t( + { _t( "%(brand)s is securely caching encrypted messages locally for them " + "to appear in search results:", { brand }, - )} + ) }
- {crawlerState}
- {_t("Space used:")} {formatBytes(this.state.eventIndexSize, 0)}
- {_t("Indexed messages:")} {formatCountLong(this.state.eventCount)}
- {_t("Indexed rooms:")} {_t("%(doneRooms)s out of %(totalRooms)s", { + { crawlerState }
+ { _t("Space used:") } { formatBytes(this.state.eventIndexSize, 0) }
+ { _t("Indexed messages:") } { formatCountLong(this.state.eventCount) }
+ { _t("Indexed rooms:") } { _t("%(doneRooms)s out of %(totalRooms)s", { doneRooms: formatCountLong(doneRooms), totalRooms: formatCountLong(this.state.roomCount), - })}
+ }) }
); - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return ( - {eventIndexingSettings} + { eventIndexingSettings } { - this.setState({phase: PHASE_OPTOUT_CONFIRM}); + this.setState({ phase: PHASE_OPTOUT_CONFIRM }); } _onSetUpClick = () => { - this.setState({phase: PHASE_PASSPHRASE}); + this.setState({ phase: PHASE_PASSPHRASE }); } _onSkipPassPhraseClick = async () => { @@ -179,7 +179,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { return; } - this.setState({phase: PHASE_PASSPHRASE_CONFIRM}); + this.setState({ phase: PHASE_PASSPHRASE_CONFIRM }); }; _onPassPhraseConfirmNextClick = async (e) => { @@ -232,15 +232,15 @@ export default class CreateKeyBackupDialog extends React.PureComponent { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return
-

{_t( +

{ _t( "Warning: You should only set up key backup from a trusted computer.", {}, - { b: sub => {sub} }, - )}

-

{_t( + { b: sub => { sub } }, + ) }

+

{ _t( "We'll store an encrypted copy of your keys on our server. " + "Secure your backup with a Security Phrase.", - )}

-

{_t("For maximum security, this should be different from your account password.")}

+ ) }

+

{ _t("For maximum security, this should be different from your account password.") }

@@ -268,9 +268,9 @@ export default class CreateKeyBackupDialog extends React.PureComponent { />
- {_t("Advanced")} - - {_t("Set up with a Security Key")} + { _t("Advanced") } + + { _t("Set up with a Security Key") }
; @@ -299,19 +299,19 @@ export default class CreateKeyBackupDialog extends React.PureComponent { let passPhraseMatch = null; if (matchText) { passPhraseMatch =
-
{matchText}
+
{ matchText }
- {changeText} + { changeText }
; } const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return
-

{_t( +

{ _t( "Enter your Security Phrase a second time to confirm it.", - )}

+ ) }

@@ -323,7 +323,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { autoFocus={true} />
- {passPhraseMatch} + { passPhraseMatch }
-

{_t( +

{ _t( "Your Security Key is a safety net - you can use it to restore " + "access to your encrypted messages if you forget your Security Phrase.", - )}

-

{_t( + ) }

+

{ _t( "Keep a copy of it somewhere secure, like a password manager or even a safe.", - )}

+ ) }

- {_t("Your Security Key")} + { _t("Your Security Key") }
- {this._keyBackupInfo.recovery_key} + { this._keyBackupInfo.recovery_key }
@@ -370,26 +370,26 @@ export default class CreateKeyBackupDialog extends React.PureComponent { if (this.state.copied) { introText = _t( "Your Security Key has been copied to your clipboard, paste it to:", - {}, {b: s => {s}}, + {}, { b: s => { s } }, ); } else if (this.state.downloaded) { introText = _t( "Your Security Key is in your Downloads folder.", - {}, {b: s => {s}}, + {}, { b: s => { s } }, ); } const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return
- {introText} + { introText }
    -
  • {_t("Print it and store it somewhere safe", {}, {b: s => {s}})}
  • -
  • {_t("Save it on a USB key or backup drive", {}, {b: s => {s}})}
  • -
  • {_t("Copy it to your personal cloud storage", {}, {b: s => {s}})}
  • +
  • { _t("Print it and store it somewhere safe", {}, { b: s => { s } }) }
  • +
  • { _t("Save it on a USB key or backup drive", {}, { b: s => { s } }) }
  • +
  • { _t("Copy it to your personal cloud storage", {}, { b: s => { s } }) }
- +
; } @@ -404,9 +404,9 @@ export default class CreateKeyBackupDialog extends React.PureComponent { _renderPhaseDone() { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return
-

{_t( +

{ _t( "Your keys are being backed up (the first backup could take a few minutes).", - )}

+ ) }

- {_t( + { _t( "Without setting up Secure Message Recovery, you won't be able to restore your " + "encrypted message history if you log out or use another session.", - )} + ) } -

{_t("Unable to create key backup")}

+

{ _t("Unable to create key backup") }

- {content} + { content }
); diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js index 6d5703a768..5fbc97d2f1 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js @@ -15,16 +15,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from 'react'; +import React, { createRef } from 'react'; import PropTypes from 'prop-types'; import * as sdk from '../../../../index'; -import {MatrixClientPeg} from '../../../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../../../MatrixClientPeg'; import FileSaver from 'file-saver'; -import {_t, _td} from '../../../../languageHandler'; +import { _t, _td } from '../../../../languageHandler'; import Modal from '../../../../Modal'; import { promptForBackupPassphrase } from '../../../../SecurityManager'; -import {copyNode} from "../../../../utils/strings"; -import {SSOAuthEntry} from "../../../../components/views/auth/InteractiveAuthEntryComponents"; +import { copyNode } from "../../../../utils/strings"; +import { SSOAuthEntry } from "../../../../components/views/auth/InteractiveAuthEntryComponents"; import PassphraseField from "../../../../components/views/auth/PassphraseField"; import StyledRadioButton from '../../../../components/views/elements/StyledRadioButton'; import AccessibleButton from "../../../../components/views/elements/AccessibleButton"; @@ -34,6 +34,8 @@ import RestoreKeyBackupDialog from "../../../../components/views/dialogs/securit import { getSecureBackupSetupMethods, isSecureBackupRequired } from '../../../../utils/WellKnownUtils'; import SecurityCustomisations from "../../../../customisations/Security"; +import { logger } from "matrix-js-sdk/src/logger"; + const PHASE_LOADING = 0; const PHASE_LOADERROR = 1; const PHASE_CHOOSE_KEY_PASSPHRASE = 2; @@ -122,7 +124,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { _getInitialPhase() { const keyFromCustomisations = SecurityCustomisations.createSecretStorageKey?.(); if (keyFromCustomisations) { - console.log("Created key via customisations, jumping to bootstrap step"); + logger.log("Created key via customisations, jumping to bootstrap step"); this._recoveryKey = { privateKey: keyFromCustomisations, }; @@ -138,7 +140,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); const backupSigStatus = ( // we may not have started crypto yet, in which case we definitely don't trust the backup - MatrixClientPeg.get().isCryptoEnabled() && await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo) + MatrixClientPeg.get().isCryptoEnabled() && (await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo)) ); const { forceReset } = this.props; @@ -155,7 +157,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { backupSigStatus, }; } catch (e) { - this.setState({phase: PHASE_LOADERROR}); + this.setState({ phase: PHASE_LOADERROR }); } } @@ -165,10 +167,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent { // We should never get here: the server should always require // UI auth to upload device signing keys. If we do, we upload // no keys which would be a no-op. - console.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!"); + logger.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!"); } catch (error) { if (!error.data || !error.data.flows) { - console.log("uploadDeviceSigningKeys advertised no flows!"); + logger.log("uploadDeviceSigningKeys advertised no flows!"); return; } const canUploadKeysWithPasswordOnly = error.data.flows.some(f => { @@ -304,7 +306,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { try { if (forceReset) { - console.log("Forcing secret storage reset"); + logger.log("Forcing secret storage reset"); await cli.bootstrapSecretStorage({ createSecretStorageKey: async () => this._recoveryKey, setupNewKeyBackup: true, @@ -385,7 +387,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } _onLoadRetryClick = () => { - this.setState({phase: PHASE_LOADING}); + this.setState({ phase: PHASE_LOADING }); this._fetchBackupInfo(); } @@ -394,11 +396,11 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } _onCancelClick = () => { - this.setState({phase: PHASE_CONFIRM_SKIP}); + this.setState({ phase: PHASE_CONFIRM_SKIP }); } _onGoBackClick = () => { - this.setState({phase: PHASE_CHOOSE_KEY_PASSPHRASE}); + this.setState({ phase: PHASE_CHOOSE_KEY_PASSPHRASE }); } _onPassPhraseNextClick = async (e) => { @@ -412,7 +414,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { return; } - this.setState({phase: PHASE_PASSPHRASE_CONFIRM}); + this.setState({ phase: PHASE_PASSPHRASE_CONFIRM }); }; _onPassPhraseConfirmNextClick = async (e) => { @@ -474,10 +476,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent { outlined >
- - {_t("Generate a Security Key")} + + { _t("Generate a Security Key") }
-
{_t("We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.")}
+
{ _t("We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.") }
); } @@ -493,10 +495,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent { outlined >
- - {_t("Enter a Security Phrase")} + + { _t("Enter a Security Phrase") }
-
{_t("Use a secret phrase only you know, and optionally save a Security Key to use for backup.")}
+
{ _t("Use a secret phrase only you know, and optionally save a Security Key to use for backup.") }
); } @@ -507,13 +509,13 @@ export default class CreateSecretStorageDialog extends React.PureComponent { const optionPassphrase = setupMethods.includes("passphrase") ? this._renderOptionPassphrase() : null; return -

{_t( +

{ _t( "Safeguard against losing access to encrypted messages & data by " + "backing up encryption keys on your server.", - )}

+ ) }

- {optionKey} - {optionPassphrase} + { optionKey } + { optionPassphrase }
-
{_t("Enter your account password to confirm the upgrade:")}
+
{ _t("Enter your account password to confirm the upgrade:") }
; } else if (!this.state.backupSigStatus.usable) { authPrompt =
-
{_t("Restore your key backup to upgrade your encryption")}
+
{ _t("Restore your key backup to upgrade your encryption") }
; nextCaption = _t("Restore"); } else { authPrompt =

- {_t("You'll need to authenticate with the server to confirm the upgrade.")} + { _t("You'll need to authenticate with the server to confirm the upgrade.") }

; } return -

{_t( +

{ _t( "Upgrade this session to allow it to verify other sessions, " + "granting them access to encrypted messages and marking them " + "as trusted for other users.", - )}

-
{authPrompt}
+ ) }

+
{ authPrompt }
; @@ -579,10 +581,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent { _renderPhasePassPhrase() { return
-

{_t( +

{ _t( "Enter a security phrase only you know, as it’s used to safeguard your data. " + "To be secure, you shouldn’t re-use your account password.", - )}

+ ) }

{_t("Cancel")} + >{ _t("Cancel") } ; } @@ -637,18 +639,18 @@ export default class CreateSecretStorageDialog extends React.PureComponent { let passPhraseMatch = null; if (matchText) { passPhraseMatch =
-
{matchText}
+
{ matchText }
- {changeText} + { changeText }
; } return
-

{_t( +

{ _t( "Enter your Security Phrase a second time to confirm it.", - )}

+ ) }

- {passPhraseMatch} + { passPhraseMatch }
{_t("Skip")} + >{ _t("Skip") } ; } @@ -691,35 +693,36 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
; } return
-

{_t( +

{ _t( "Store your Security Key somewhere safe, like a password manager or a safe, " + "as it’s used to safeguard your encrypted data.", - )}

+ ) }

- {this._recoveryKey.encodedPrivateKey} + { this._recoveryKey.encodedPrivateKey }
- - {_t("Download")} + { _t("Download") } - {_t("or")} + { _t("or") } - {this.state.copied ? _t("Copied!") : _t("Copy")} + { this.state.copied ? _t("Copied!") : _t("Copy") }
- {continueButton} + { continueButton }
; } @@ -732,7 +735,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { _renderPhaseLoadError() { return
-

{_t("Unable to query secret storage status")}

+

{ _t("Unable to query secret storage status") }

-

{_t( +

{ _t( "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.", - )}

-

{_t( + ) }

+

{ _t( "You can also set up Secure Backup & manage your keys in Settings.", - )}

+ ) }

- +
; } @@ -787,7 +790,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { let content; if (this.state.error) { content =
-

{_t("Unable to set up secret storage")}

+

{ _t("Unable to set up secret storage") }

- {content} + { content }
); diff --git a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js index 60f2ca9168..dbed9f3968 100644 --- a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js @@ -15,7 +15,7 @@ limitations under the License. */ import FileSaver from 'file-saver'; -import React, {createRef} from 'react'; +import React, { createRef } from 'react'; import PropTypes from 'prop-types'; import { _t } from '../../../../languageHandler'; @@ -55,11 +55,11 @@ export default class ExportE2eKeysDialog extends React.Component { const passphrase = this._passphrase1.current.value; if (passphrase !== this._passphrase2.current.value) { - this.setState({errStr: _t('Passphrases must match')}); + this.setState({ errStr: _t('Passphrases must match') }); return false; } if (!passphrase) { - this.setState({errStr: _t('Passphrase must not be empty')}); + this.setState({ errStr: _t('Passphrase must not be empty') }); return false; } @@ -148,8 +148,12 @@ export default class ExportE2eKeysDialog extends React.Component {
-
@@ -161,8 +165,10 @@ export default class ExportE2eKeysDialog extends React.Component {
-
diff --git a/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js index 70fc997230..0936ad696d 100644 --- a/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from 'react'; +import React, { createRef } from 'react'; import PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk/src/client'; @@ -174,7 +174,10 @@ export default class ImportE2eKeysDialog extends React.Component {
- + + ; +}; + +const SpaceHierarchy = ({ + space, + initialText = "", + showRoom, + additionalButtons, +}: IProps) => { + const cli = useContext(MatrixClientContext); + const [query, setQuery] = useState(initialText); + + const [selected, setSelected] = useState(new Map>()); // Map> + + const { loading, rooms, hierarchy, loadMore } = useSpaceSummary(space); + + const filteredRoomSet = useMemo>(() => { + if (!rooms?.length) return new Set(); + const lcQuery = query.toLowerCase().trim(); + if (!lcQuery) return new Set(rooms); + + const directMatches = rooms.filter(r => { + return r.name?.toLowerCase().includes(lcQuery) || r.topic?.toLowerCase().includes(lcQuery); + }); + + // Walk back up the tree to find all parents of the direct matches to show their place in the hierarchy + const visited = new Set(); + const queue = [...directMatches.map(r => r.room_id)]; + while (queue.length) { + const roomId = queue.pop(); + visited.add(roomId); + hierarchy.backRefs.get(roomId)?.forEach(parentId => { + if (!visited.has(parentId)) { + queue.push(parentId); + } + }); + } + + return new Set(rooms.filter(r => visited.has(r.room_id))); + }, [rooms, hierarchy, query]); + + const [error, setError] = useState(""); + + const loaderRef = useIntersectionObserver(loadMore); + + if (!loading && hierarchy.noSupport) { + return

{ _t("Your server does not support showing space hierarchies.") }

; + } + + const onKeyDown = (ev: KeyboardEvent, state: IState): void => { + if (ev.key === Key.ARROW_DOWN && ev.currentTarget.classList.contains("mx_SpaceHierarchy_search")) { + state.refs[0]?.current?.focus(); + } + }; + + const onToggleClick = (parentId: string, childId: string): void => { + setError(""); + if (!selected.has(parentId)) { + setSelected(new Map(selected.set(parentId, new Set([childId])))); + return; + } + + const parentSet = selected.get(parentId); + if (!parentSet.has(childId)) { + setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId])))); + return; + } + + parentSet.delete(childId); + setSelected(new Map(selected.set(parentId, new Set(parentSet)))); + }; + + return + { ({ onKeyDownHandler }) => { + let content: JSX.Element; + if (loading && !rooms.length) { + content = ; + } else { + const hasPermissions = space?.getMyMembership() === "join" && + space.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId()); + + let results: JSX.Element; + if (filteredRoomSet.size) { + results = <> + { + showRoom(cli, hierarchy, roomId, autoJoin, roomType); + }} + /> + ; + } else if (!hierarchy.canLoadMore) { + results =
+

{ _t("No results found") }

+
{ _t("You may want to try a different search or check for typos.") }
+
; + } + + let loader: JSX.Element; + if (hierarchy.canLoadMore) { + loader =
+ +
; + } + + content = <> +
+

{ query.trim() ? _t("Results") : _t("Rooms and spaces") }

+ + { additionalButtons } + { hasPermissions && ( + + ) } + +
+ { error &&
+ { error } +
} +
    + { results } +
+ { loader } + ; + } + + return <> + + + { content } + ; + } } +
; +}; + +export default SpaceHierarchy; diff --git a/src/components/structures/SpaceRoomDirectory.tsx b/src/components/structures/SpaceRoomDirectory.tsx deleted file mode 100644 index 8d59fe6c68..0000000000 --- a/src/components/structures/SpaceRoomDirectory.tsx +++ /dev/null @@ -1,669 +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, {ReactNode, useMemo, useState} from "react"; -import {Room} from "matrix-js-sdk/src/models/room"; -import {MatrixClient} from "matrix-js-sdk/src/client"; -import {EventType, RoomType} from "matrix-js-sdk/src/@types/event"; -import classNames from "classnames"; -import {sortBy} from "lodash"; - -import {MatrixClientPeg} from "../../MatrixClientPeg"; -import dis from "../../dispatcher/dispatcher"; -import {_t} from "../../languageHandler"; -import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton"; -import BaseDialog from "../views/dialogs/BaseDialog"; -import Spinner from "../views/elements/Spinner"; -import SearchBox from "./SearchBox"; -import RoomAvatar from "../views/avatars/RoomAvatar"; -import RoomName from "../views/elements/RoomName"; -import {useAsyncMemo} from "../../hooks/useAsyncMemo"; -import {EnhancedMap} from "../../utils/maps"; -import StyledCheckbox from "../views/elements/StyledCheckbox"; -import AutoHideScrollbar from "./AutoHideScrollbar"; -import BaseAvatar from "../views/avatars/BaseAvatar"; -import {mediaFromMxc} from "../../customisations/Media"; -import InfoTooltip from "../views/elements/InfoTooltip"; -import TextWithTooltip from "../views/elements/TextWithTooltip"; -import {useStateToggle} from "../../hooks/useStateToggle"; -import {getOrder} from "../../stores/SpaceStore"; -import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; -import {linkifyElement} from "../../HtmlUtils"; - -interface IHierarchyProps { - space: Room; - initialText?: string; - refreshToken?: any; - additionalButtons?: ReactNode; - showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void; -} - -/* eslint-disable camelcase */ -export interface ISpaceSummaryRoom { - canonical_alias?: string; - aliases: string[]; - avatar_url?: string; - guest_can_join: boolean; - name?: string; - num_joined_members: number - room_id: string; - topic?: string; - world_readable: boolean; - num_refs: number; - room_type: string; -} - -export interface ISpaceSummaryEvent { - room_id: string; - event_id: string; - origin_server_ts: number; - type: string; - state_key: string; - content: { - order?: string; - suggested?: boolean; - auto_join?: boolean; - via?: string[]; - }; -} -/* eslint-enable camelcase */ - -interface ITileProps { - room: ISpaceSummaryRoom; - suggested?: boolean; - selected?: boolean; - numChildRooms?: number; - hasPermissions?: boolean; - onViewRoomClick(autoJoin: boolean): void; - onToggleClick?(): void; -} - -const Tile: React.FC = ({ - room, - suggested, - selected, - hasPermissions, - onToggleClick, - onViewRoomClick, - numChildRooms, - children, -}) => { - const cli = MatrixClientPeg.get(); - const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null; - const name = joinedRoom?.name || room.name || room.canonical_alias || room.aliases?.[0] - || (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room")); - - const [showChildren, toggleShowChildren] = useStateToggle(true); - - const onPreviewClick = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - onViewRoomClick(false); - } - const onJoinClick = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - onViewRoomClick(true); - } - - let button; - if (joinedRoom) { - button = - { _t("View") } - ; - } else if (onJoinClick) { - button = - { _t("Join") } - ; - } - - let checkbox; - if (onToggleClick) { - if (hasPermissions) { - checkbox = ; - } else { - checkbox = { ev.stopPropagation() }} - > - - ; - } - } - - let avatar; - if (joinedRoom) { - avatar = ; - } else { - avatar = ; - } - - let description = _t("%(count)s members", { count: room.num_joined_members }); - if (numChildRooms !== undefined) { - description += " · " + _t("%(count)s rooms", { count: numChildRooms }); - } - - const topic = joinedRoom?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || room.topic; - if (topic) { - description += " · " + topic; - } - - let suggestedSection; - if (suggested) { - suggestedSection = - { _t("Suggested") } - ; - } - - const content = - { avatar } -
- { name } - { suggestedSection } -
- -
e && linkifyElement(e)} - onClick={ev => { - // prevent clicks on links from bubbling up to the room tile - if ((ev.target as HTMLElement).tagName === "A") { - ev.stopPropagation(); - } - }} - > - { description } -
-
- { button } - { checkbox } -
-
; - - let childToggle; - let childSection; - if (children) { - // the chevron is purposefully a div rather than a button as it should be ignored for a11y - childToggle =
{ - ev.stopPropagation(); - toggleShowChildren(); - }} - />; - if (showChildren) { - childSection =
- { children } -
; - } - } - - return <> - - { content } - { childToggle } - - { childSection } - ; -}; - -export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => { - // Don't let the user view a room they won't be able to either peek or join: - // fail earlier so they don't have to click back to the directory. - if (MatrixClientPeg.get().isGuest()) { - if (!room.world_readable && !room.guest_can_join) { - dis.dispatch({ action: "require_registration" }); - return; - } - } - - const roomAlias = getDisplayAliasForRoom(room) || undefined; - dis.dispatch({ - action: "view_room", - auto_join: autoJoin, - should_peek: true, - _type: "room_directory", // instrumentation - room_alias: roomAlias, - room_id: room.room_id, - via_servers: viaServers, - oob_data: { - avatarUrl: room.avatar_url, - // XXX: This logic is duplicated from the JS SDK which would normally decide what the name is. - name: room.name || roomAlias || _t("Unnamed room"), - }, - }); -}; - -interface IHierarchyLevelProps { - spaceId: string; - rooms: Map; - relations: Map>; - parents: Set; - selectedMap?: Map>; - onViewRoomClick(roomId: string, autoJoin: boolean): void; - onToggleClick?(parentId: string, childId: string): void; -} - -export const HierarchyLevel = ({ - spaceId, - rooms, - relations, - parents, - selectedMap, - onViewRoomClick, - onToggleClick, -}: IHierarchyLevelProps) => { - const cli = MatrixClientPeg.get(); - const space = cli.getRoom(spaceId); - const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId()); - - const children = Array.from(relations.get(spaceId)?.values() || []); - const sortedChildren = sortBy(children, ev => { - // XXX: Space Summary API doesn't give the child origin_server_ts but once it does we should use it for sorting - return getOrder(ev.content.order, null, ev.state_key); - }); - const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => { - const roomId = ev.state_key; - if (!rooms.has(roomId)) return result; - result[rooms.get(roomId).room_type === RoomType.Space ? 0 : 1].push(roomId); - return result; - }, [[], []]) || [[], []]; - - const newParents = new Set(parents).add(spaceId); - return - { - childRooms.map(roomId => ( - { - onViewRoomClick(roomId, autoJoin); - }} - hasPermissions={hasPermissions} - onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined} - /> - )) - } - - { - subspaces.filter(roomId => !newParents.has(roomId)).map(roomId => ( - rooms.has(ev.state_key) && !rooms.get(ev.state_key).room_type).length} - suggested={relations.get(spaceId)?.get(roomId)?.content.suggested} - selected={selectedMap?.get(spaceId)?.has(roomId)} - onViewRoomClick={(autoJoin) => { - onViewRoomClick(roomId, autoJoin); - }} - hasPermissions={hasPermissions} - onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined} - > - - - )) - } - -}; - -// mutate argument refreshToken to force a reload -export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [ - null, - ISpaceSummaryRoom[], - Map>?, - Map>?, - Map>?, -] | [Error] => { - // TODO pagination - return useAsyncMemo(async () => { - try { - const data = await cli.getSpaceSummary(space.roomId); - - const parentChildRelations = new EnhancedMap>(); - const childParentRelations = new EnhancedMap>(); - const viaMap = new EnhancedMap>(); - data.events.map((ev: ISpaceSummaryEvent) => { - if (ev.type === EventType.SpaceChild) { - parentChildRelations.getOrCreate(ev.room_id, new Map()).set(ev.state_key, ev); - childParentRelations.getOrCreate(ev.state_key, new Set()).add(ev.room_id); - } - if (Array.isArray(ev.content.via)) { - const set = viaMap.getOrCreate(ev.state_key, new Set()); - ev.content.via.forEach(via => set.add(via)); - } - }); - - return [null, data.rooms as ISpaceSummaryRoom[], parentChildRelations, viaMap, childParentRelations]; - } catch (e) { - console.error(e); // TODO - return [e]; - } - }, [space, refreshToken], [undefined]); -}; - -export const SpaceHierarchy: React.FC = ({ - space, - initialText = "", - showRoom, - refreshToken, - additionalButtons, - children, -}) => { - const cli = MatrixClientPeg.get(); - const userId = cli.getUserId(); - const [query, setQuery] = useState(initialText); - - const [selected, setSelected] = useState(new Map>()); // Map> - - const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken); - - const roomsMap = useMemo(() => { - if (!rooms) return null; - const lcQuery = query.toLowerCase().trim(); - - const roomsMap = new Map(rooms.map(r => [r.room_id, r])); - if (!lcQuery) return roomsMap; - - const directMatches = rooms.filter(r => { - return r.name?.toLowerCase().includes(lcQuery) || r.topic?.toLowerCase().includes(lcQuery); - }); - - // Walk back up the tree to find all parents of the direct matches to show their place in the hierarchy - const visited = new Set(); - const queue = [...directMatches.map(r => r.room_id)]; - while (queue.length) { - const roomId = queue.pop(); - visited.add(roomId); - childParentMap.get(roomId)?.forEach(parentId => { - if (!visited.has(parentId)) { - queue.push(parentId); - } - }); - } - - // Remove any mappings for rooms which were not visited in the walk - Array.from(roomsMap.keys()).forEach(roomId => { - if (!visited.has(roomId)) { - roomsMap.delete(roomId); - } - }); - return roomsMap; - }, [rooms, childParentMap, query]); - - const [error, setError] = useState(""); - const [removing, setRemoving] = useState(false); - const [saving, setSaving] = useState(false); - - if (summaryError) { - return

{_t("Your server does not support showing space hierarchies.")}

; - } - - let content; - if (roomsMap) { - const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length; - const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at - - let countsStr; - if (numSpaces > 1) { - countsStr = _t("%(count)s rooms and %(numSpaces)s spaces", { count: numRooms, numSpaces }); - } else if (numSpaces > 0) { - countsStr = _t("%(count)s rooms and 1 space", { count: numRooms, numSpaces }); - } else { - countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces }); - } - - let manageButtons; - if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) { - const selectedRelations = Array.from(selected.keys()).flatMap(parentId => { - return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][]; - }); - - const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => { - return parentChildMap.get(parentId)?.get(childId)?.content.suggested; - }); - - const disabled = !selectedRelations.length || removing || saving; - - let Button: React.ComponentType> = AccessibleButton; - let props = {}; - if (!selectedRelations.length) { - Button = AccessibleTooltipButton; - props = { - tooltip: _t("Select a room below first"), - yOffset: -40, - }; - } - - manageButtons = <> - - - ; - } - - let results; - if (roomsMap.size) { - const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId()); - - results = <> - { - setError(""); - if (!selected.has(parentId)) { - setSelected(new Map(selected.set(parentId, new Set([childId])))); - return; - } - - const parentSet = selected.get(parentId); - if (!parentSet.has(childId)) { - setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId])))); - return; - } - - parentSet.delete(childId); - setSelected(new Map(selected.set(parentId, new Set(parentSet)))); - } : undefined} - onViewRoomClick={(roomId, autoJoin) => { - showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin); - }} - /> - { children &&
} - ; - } else { - results =
-

{ _t("No results found") }

-
{ _t("You may want to try a different search or check for typos.") }
-
; - } - - content = <> -
- { countsStr } - - { additionalButtons } - { manageButtons } - -
- { error &&
- { error } -
} - - { results } - { children } - - ; - } else { - content = ; - } - - // TODO loading state/error state - return <> - - - { content } - ; -}; - -interface IProps { - space: Room; - initialText?: string; - onFinished(): void; -} - -const SpaceRoomDirectory: React.FC = ({ space, onFinished, initialText }) => { - const onCreateRoomClick = () => { - dis.dispatch({ - action: 'view_create_room', - public: true, - }); - onFinished(); - }; - - const title = - -
-

{ _t("Explore rooms") }

-
-
-
; - - return ( - -
- { _t("If you can't find the room you're looking for, ask for an invite or create a new room.", - null, - {a: sub => { - return {sub}; - }}, - ) } - - { - showRoom(room, viaServers, autoJoin); - onFinished(); - }} - initialText={initialText} - > - - { _t("Create room") } - - -
-
- ); -}; - -export default SpaceRoomDirectory; - -// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom -// but works with the objects we get from the public room list -function getDisplayAliasForRoom(room: ISpaceSummaryRoom) { - return room.canonical_alias || (room.aliases ? room.aliases[0] : ""); -} diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 06d2bda16e..270db21408 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -14,57 +14,73 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {RefObject, useContext, useRef, useState} from "react"; -import {EventType} from "matrix-js-sdk/src/@types/event"; -import {Room} from "matrix-js-sdk/src/models/room"; -import {EventSubscription} from "fbemitter"; +import React, { RefObject, useContext, useRef, useState } from "react"; +import { EventType } from "matrix-js-sdk/src/@types/event"; +import { JoinRule, Preset } from "matrix-js-sdk/src/@types/partials"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { EventSubscription } from "fbemitter"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import RoomAvatar from "../views/avatars/RoomAvatar"; -import {_t} from "../../languageHandler"; +import { _t } from "../../languageHandler"; import AccessibleButton from "../views/elements/AccessibleButton"; import RoomName from "../views/elements/RoomName"; import RoomTopic from "../views/elements/RoomTopic"; import InlineSpinner from "../views/elements/InlineSpinner"; -import {inviteMultipleToRoom, showRoomInviteDialog} from "../../RoomInvite"; -import {useRoomMembers} from "../../hooks/useRoomMembers"; -import createRoom, {IOpts, Preset} from "../../createRoom"; +import { inviteMultipleToRoom, showRoomInviteDialog } from "../../RoomInvite"; +import { useRoomMembers } from "../../hooks/useRoomMembers"; +import createRoom, { IOpts } from "../../createRoom"; import Field from "../views/elements/Field"; -import {useEventEmitter} from "../../hooks/useEventEmitter"; +import { useEventEmitter } from "../../hooks/useEventEmitter"; import withValidation from "../views/elements/Validation"; import * as Email from "../../email"; import defaultDispatcher from "../../dispatcher/dispatcher"; -import {Action} from "../../dispatcher/actions"; -import ResizeNotifier from "../../utils/ResizeNotifier" +import dis from "../../dispatcher/dispatcher"; +import { Action } from "../../dispatcher/actions"; +import ResizeNotifier from "../../utils/ResizeNotifier"; import MainSplit from './MainSplit'; import ErrorBoundary from "../views/elements/ErrorBoundary"; -import {ActionPayload} from "../../dispatcher/payloads"; +import { ActionPayload } from "../../dispatcher/payloads"; import RightPanel from "./RightPanel"; import RightPanelStore from "../../stores/RightPanelStore"; -import {RightPanelPhases} from "../../stores/RightPanelStorePhases"; -import {SetRightPanelPhasePayload} from "../../dispatcher/payloads/SetRightPanelPhasePayload"; -import {useStateArray} from "../../hooks/useStateArray"; +import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; +import { SetRightPanelPhasePayload } from "../../dispatcher/payloads/SetRightPanelPhasePayload"; +import { useStateArray } from "../../hooks/useStateArray"; import SpacePublicShare from "../views/spaces/SpacePublicShare"; -import {showAddExistingRooms, showCreateNewRoom, shouldShowSpaceSettings, showSpaceSettings} from "../../utils/space"; -import {showRoom, SpaceHierarchy} from "./SpaceRoomDirectory"; +import { + shouldShowSpaceSettings, + showAddExistingRooms, + showCreateNewRoom, + showCreateNewSubspace, + showSpaceSettings, +} from "../../utils/space"; +import SpaceHierarchy, { showRoom } from "./SpaceHierarchy"; import MemberAvatar from "../views/avatars/MemberAvatar"; -import {useStateToggle} from "../../hooks/useStateToggle"; import SpaceStore from "../../stores/SpaceStore"; import FacePile from "../views/elements/FacePile"; -import {AddExistingToSpace} from "../views/dialogs/AddExistingToSpaceDialog"; -import {ChevronFace, ContextMenuButton, useContextMenu} from "./ContextMenu"; +import { + AddExistingToSpace, + defaultDmsRenderer, + defaultRoomsRenderer, + defaultSpacesRenderer, +} from "../views/dialogs/AddExistingToSpaceDialog"; +import { ChevronFace, ContextMenuButton, useContextMenu } from "./ContextMenu"; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList, } from "../views/context_menus/IconizedContextMenu"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; -import {BetaPill} from "../views/beta/BetaCard"; -import {USER_LABS_TAB} from "../views/dialogs/UserSettingsDialog"; -import SettingsStore from "../../settings/SettingsStore"; -import dis from "../../dispatcher/dispatcher"; -import Modal from "../../Modal"; -import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog"; -import SdkConfig from "../../SdkConfig"; +import { BetaPill } from "../views/beta/BetaCard"; +import { UserTab } from "../views/dialogs/UserSettingsDialog"; +import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership"; +import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu"; +import { CreateEventField, IGroupSummary } from "../views/dialogs/CreateSpaceFromCommunityDialog"; +import { useAsyncMemo } from "../../hooks/useAsyncMemo"; +import Spinner from "../views/elements/Spinner"; +import GroupAvatar from "../views/avatars/GroupAvatar"; +import { useDispatcher } from "../../hooks/useDispatcher"; + +import { logger } from "matrix-js-sdk/src/logger"; interface IProps { space: Room; @@ -76,7 +92,7 @@ interface IProps { interface IState { phase: Phase; - createdRooms?: boolean; // internal state for the creation wizard + firstRoomId?: string; // internal state for the creation wizard showRightPanel: boolean; myMembership: string; } @@ -91,26 +107,6 @@ enum Phase { PrivateExistingRooms, } -// XXX: Temporary for the Spaces Beta only -export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => { - if (!SdkConfig.get().bug_report_endpoint_url) return null; - - return
-
-
- { _t("Spaces are a beta feature.") } - { - if (onClick) onClick(); - Modal.createTrackedDialog("Beta Feedback", "feature_spaces", BetaFeedbackDialog, { - featureId: "feature_spaces", - }); - }}> - { _t("Feedback") } - -
-
; -}; - const RoomMemberCount = ({ room, children }) => { const members = useRoomMembers(room); const count = members.length; @@ -144,7 +140,7 @@ const SpaceInfo = ({ space }) => { return
{ visibilitySection } { joinRule === "public" && - {(count) => count > 0 ? ( + { (count) => count > 0 ? ( { @@ -157,25 +153,59 @@ const SpaceInfo = ({ space }) => { > { _t("%(count)s members", { count }) } - ) : null} + ) : null } } -
+
; }; -const onBetaClick = () => { +const onPreferencesClick = () => { defaultDispatcher.dispatch({ action: Action.ViewUserSettings, - initialTabId: USER_LABS_TAB, + initialTabId: UserTab.Preferences, }); }; -const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => { +// XXX: temporary community migration component +const GroupTile = ({ groupId }: { groupId: string }) => { + const cli = useContext(MatrixClientContext); + const groupSummary = useAsyncMemo(() => cli.getGroupSummary(groupId), [cli, groupId]); + + if (!groupSummary) return ; + + return <> + + { groupSummary.profile.name } + ; +}; + +interface ISpacePreviewProps { + space: Room; + onJoinButtonClicked(): void; + onRejectButtonClicked(): void; +} + +const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISpacePreviewProps) => { const cli = useContext(MatrixClientContext); const myMembership = useMyRoomMembership(space); + useDispatcher(defaultDispatcher, payload => { + if (payload.action === Action.JoinRoomError && payload.roomId === space.roomId) { + setBusy(false); // stop the spinner, join failed + } + }); const [busy, setBusy] = useState(false); - const spacesEnabled = SettingsStore.getValue("feature_spaces"); + const spacesEnabled = SpaceStore.spacesEnabled; + + const cannotJoin = getEffectiveMembership(myMembership) === EffectiveMembership.Leave + && space.getJoinRule() !== JoinRule.Public; let inviterSection; let joinButtons; @@ -200,11 +230,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => if (inviteSender) { inviterSection =
- +
{ _t(" invites you", {}, { - inviter: () => { inviter.name || inviteSender }, + inviter: () => { inviter?.name || inviteSender }, }) }
{ inviter ?
@@ -243,7 +273,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => setBusy(true); onJoinButtonClicked(); }} - disabled={!spacesEnabled} + disabled={!spacesEnabled || cannotJoin} > { _t("Join") } @@ -254,8 +284,38 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => joinButtons = ; } + let footer; + if (!spacesEnabled) { + footer =
+ { myMembership === "join" + ? _t("To view this Space, hide communities in your preferences", {}, { + a: sub => { sub }, + }) + : _t("To join this Space, hide communities in your preferences", {}, { + a: sub => { sub }, + }) + } +
; + } else if (cannotJoin) { + footer =
+ { _t("To view %(spaceName)s, you need an invite", { + spaceName: space.name, + }) } +
; + } + + let migratedCommunitySection: JSX.Element; + const createContent = space.currentState.getStateEvents(EventType.RoomCreate, "")?.getContent(); + if (createContent[CreateEventField]) { + migratedCommunitySection =
+ { _t("Created from ", {}, { + Community: () => , + }) } +
; + } + return
- + { migratedCommunitySection } { inviterSection }

@@ -263,7 +323,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>

- {(topic, ref) => + { (topic, ref) =>
{ topic }
@@ -273,25 +333,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
{ joinButtons }
- { !spacesEnabled &&
- { myMembership === "join" - ? _t("To view %(spaceName)s, turn on the Spaces beta", { - spaceName: space.name, - }, { - a: sub => { sub }, - }) - : _t("To join %(spaceName)s, turn on the Spaces beta", { - spaceName: space.name, - }, { - a: sub => { sub }, - }) - } -
} + { footer }
; }; -const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => { - const cli = useContext(MatrixClientContext); +const SpaceLandingAddButton = ({ space }) => { const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); let contextMenu; @@ -314,25 +360,33 @@ const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => { e.stopPropagation(); closeMenu(); - if (await showCreateNewRoom(cli, space)) { - onNewRoomAdded(); + if (await showCreateNewRoom(space)) { + defaultDispatcher.fire(Action.UpdateSpaceHierarchy); } }} /> { + onClick={(e) => { e.preventDefault(); e.stopPropagation(); closeMenu(); - - const [added] = await showAddExistingRooms(cli, space); - if (added) { - onNewRoomAdded(); - } + showAddExistingRooms(space); }} /> + { + e.preventDefault(); + e.stopPropagation(); + closeMenu(); + showCreateNewSubspace(space); + }} + > + + ; } @@ -373,19 +427,17 @@ const SpaceLanding = ({ space }) => { const canAddRooms = myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId); - const [refreshToken, forceUpdate] = useStateToggle(false); - let addRoomButton; if (canAddRooms) { - addRoomButton = ; + addRoomButton = ; } let settingsButton; - if (shouldShowSpaceSettings(cli, space)) { + if (shouldShowSpaceSettings(space)) { settingsButton = { - showSpaceSettings(cli, space); + showSpaceSettings(space); }} title={_t("Settings")} />; @@ -400,15 +452,16 @@ const SpaceLanding = ({ space }) => { }; return
+
- {(name) => { + { (name) => { const tags = { name: () =>

{ name }

}; return _t("Welcome to ", {}, tags) as JSX.Element; - }} + } }
@@ -418,21 +471,14 @@ const SpaceLanding = ({ space }) => { { settingsButton }
- {(topic, ref) => ( + { (topic, ref) => (
{ topic }
- )} + ) }
- -
- +
; }; @@ -442,7 +488,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { const numFields = 3; const placeholders = [_t("General"), _t("Random"), _t("Support")]; const [roomNames, setRoomName] = useStateArray(numFields, [_t("General"), _t("Random"), ""]); - const fields = new Array(numFields).fill(0).map((_, i) => { + const fields = new Array(numFields).fill(0).map((x, i) => { const name = "roomName" + i; return { onChange={ev => setRoomName(i, ev.target.value)} autoFocus={i === 2} disabled={busy} + autoComplete="off" />; }); @@ -463,11 +510,12 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { setError(""); setBusy(true); try { + const isPublic = space.getJoinRule() === JoinRule.Public; const filteredRoomNames = roomNames.map(name => name.trim()).filter(Boolean); - await Promise.all(filteredRoomNames.map(name => { + const roomIds = await Promise.all(filteredRoomNames.map(name => { return createRoom({ createOpts: { - preset: space.getJoinRule() === "public" ? Preset.PublicChat : Preset.PrivateChat, + preset: isPublic ? Preset.PublicChat : Preset.PrivateChat, name, }, spinner: false, @@ -475,9 +523,11 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { andView: false, inlineErrors: true, parentSpace: space, + joinRule: !isPublic ? JoinRule.Restricted : undefined, + suggested: true, }); })); - onFinished(filteredRoomNames.length > 0); + onFinished(roomIds[0]); } catch (e) { console.error("Failed to create initial space rooms", e); setError(_t("Failed to create initial space rooms")); @@ -487,7 +537,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { let onClick = (ev) => { ev.preventDefault(); - onFinished(false); + onFinished(); }; let buttonLabel = _t("Skip for now"); if (roomNames.some(name => name.trim())) { @@ -515,7 +565,6 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { value={buttonLabel} />
-
; }; @@ -534,17 +583,20 @@ const SpaceAddExistingRooms = ({ space, onFinished }) => { { _t("Skip for now") } } + filterPlaceholder={_t("Search for rooms or spaces")} onFinished={onFinished} + roomsRenderer={defaultRoomsRenderer} + spacesRenderer={defaultSpacesRenderer} + dmsRenderer={defaultDmsRenderer} /> - -
- -
-
; }; -const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRooms }) => { +interface ISpaceSetupPublicShareProps extends Pick { + onFinished(): void; +} + +const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, firstRoomId }: ISpaceSetupPublicShareProps) => { return

{ _t("Share %(name)s", { name: justCreatedOpts?.createOpts?.name || space.name, @@ -557,10 +609,9 @@ const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRoom
- { createdRooms ? _t("Go to my first room") : _t("Go to my space") } + { firstRoomId ? _t("Go to my first room") : _t("Go to my space") }
-

; }; @@ -575,19 +626,22 @@ const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => { { onFinished(false) }} + onClick={() => { onFinished(false); }} >

{ _t("Just me") }

{ _t("A private space to organise your rooms") }
{ onFinished(true) }} + onClick={() => { onFinished(true); }} >

{ _t("Me and my teammates") }

{ _t("A private space for you and your teammates") }
- +
+

{ _t("Teammates might not be able to view or join any private rooms you make.") }

+

{ _t("We're working on this, but just want to let you know.") }

+
; }; @@ -605,7 +659,7 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => { const numFields = 3; const fieldRefs: RefObject[] = [useRef(), useRef(), useRef()]; const [emailAddresses, setEmailAddress] = useStateArray(numFields, ""); - const fields = new Array(numFields).fill(0).map((_, i) => { + const fields = new Array(numFields).fill(0).map((x, i) => { const name = "emailAddress" + i; return { const failedUsers = Object.keys(result.states).filter(a => result.states[a] === "error"); if (failedUsers.length > 0) { - console.log("Failed to invite users to space: ", result); + logger.log("Failed to invite users to space: ", result); setError(_t("Failed to invite the following users to your space: %(csvUsers)s", { csvUsers: failedUsers.join(", "), })); @@ -665,7 +719,7 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => { let buttonLabel = _t("Skip for now"); if (emailAddresses.some(name => name.trim())) { onClick = onNextClick; - buttonLabel = busy ? _t("Inviting...") : _t("Continue") + buttonLabel = busy ? _t("Inviting...") : _t("Continue"); } return
@@ -675,7 +729,7 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
- + { _t("This is an experimental feature. For now, " + "new users receiving an invite will have to open the invite on to actually join.", {}, { b: sub => { sub }, @@ -710,7 +764,6 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => { value={buttonLabel} />
-
; }; @@ -764,6 +817,11 @@ export default class SpaceRoomView extends React.PureComponent { }; private onAction = (payload: ActionPayload) => { + if (payload.action === "view_room" && payload.room_id === this.props.space.roomId) { + this.setState({ phase: Phase.Landing }); + return; + } + if (payload.action !== Action.ViewUser && payload.action !== "view_3pid_invite") return; if (payload.action === Action.ViewUser && payload.member) { @@ -794,35 +852,10 @@ export default class SpaceRoomView extends React.PureComponent { }; private goToFirstRoom = async () => { - // TODO actually go to the first room - - const childRooms = SpaceStore.instance.getChildRooms(this.props.space.roomId); - if (childRooms.length) { - const room = childRooms[0]; + if (this.state.firstRoomId) { defaultDispatcher.dispatch({ action: "view_room", - room_id: room.roomId, - }); - return; - } - - let suggestedRooms = SpaceStore.instance.suggestedRooms; - if (SpaceStore.instance.activeSpace !== this.props.space) { - // the space store has the suggested rooms loaded for a different space, fetch the right ones - suggestedRooms = (await SpaceStore.instance.fetchSuggestedRooms(this.props.space, 1)); - } - - if (suggestedRooms.length) { - const room = suggestedRooms[0]; - defaultDispatcher.dispatch({ - action: "view_room", - room_id: room.room_id, - room_alias: room.canonical_alias || room.aliases?.[0], - via_servers: room.viaServers, - oobData: { - avatarUrl: room.avatar_url, - name: room.name || room.canonical_alias || room.aliases?.[0] || _t("Empty room"), - }, + room_id: this.state.firstRoomId, }); return; } @@ -833,7 +866,7 @@ export default class SpaceRoomView extends React.PureComponent { private renderBody() { switch (this.state.phase) { case Phase.Landing: - if (this.state.myMembership === "join" && SettingsStore.getValue("feature_spaces")) { + if (this.state.myMembership === "join" && SpaceStore.spacesEnabled) { return ; } else { return { _t("Let's create a room for each of them.") + "\n" + _t("You can add more later too, including already existing ones.") } - onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.PublicShare, createdRooms })} + onFinished={(firstRoomId: string) => this.setState({ phase: Phase.PublicShare, firstRoomId })} />; case Phase.PublicShare: return ; case Phase.PrivateScope: @@ -881,7 +914,7 @@ export default class SpaceRoomView extends React.PureComponent { title={_t("What projects are you working on?")} description={_t("We'll create rooms for each of them. " + "You can add more later too, including already existing ones.")} - onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.Landing, createdRooms })} + onFinished={(firstRoomId: string) => this.setState({ phase: Phase.Landing, firstRoomId })} />; case Phase.PrivateExistingRooms: return void; } interface IState { @@ -62,7 +70,11 @@ export default class TabbedView extends React.Component { }; } - private _getActiveTabIndex() { + static defaultProps = { + tabLocation: TabLocation.LEFT, + }; + + private getActiveTabIndex() { if (!this.state || !this.state.activeTabIndex) return 0; return this.state.activeTabIndex; } @@ -72,34 +84,33 @@ export default class TabbedView extends React.Component { * @param {Tab} tab the tab to show * @private */ - private _setActiveTab(tab: Tab) { + private setActiveTab(tab: Tab) { const idx = this.props.tabs.indexOf(tab); if (idx !== -1) { - this.setState({activeTabIndex: idx}); + if (this.props.onChange) this.props.onChange(tab.id); + this.setState({ activeTabIndex: idx }); } else { console.error("Could not find tab " + tab.label + " in tabs"); } } - private _renderTabLabel(tab: Tab) { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - + private renderTabLabel(tab: Tab) { let classes = "mx_TabbedView_tabLabel "; const idx = this.props.tabs.indexOf(tab); - if (idx === this._getActiveTabIndex()) classes += "mx_TabbedView_tabLabel_active"; + if (idx === this.getActiveTabIndex()) classes += "mx_TabbedView_tabLabel_active"; let tabIcon = null; if (tab.icon) { tabIcon = ; } - const onClickHandler = () => this._setActiveTab(tab); + const onClickHandler = () => this.setActiveTab(tab); const label = _t(tab.label); return ( - {tabIcon} + { tabIcon } { label } @@ -107,26 +118,32 @@ export default class TabbedView extends React.Component { ); } - private _renderTabPanel(tab: Tab): React.ReactNode { + private renderTabPanel(tab: Tab): React.ReactNode { return (
- {tab.body} + { tab.body }
); } public render(): React.ReactNode { - const labels = this.props.tabs.map(tab => this._renderTabLabel(tab)); - const panel = this._renderTabPanel(this.props.tabs[this._getActiveTabIndex()]); + const labels = this.props.tabs.map(tab => this.renderTabLabel(tab)); + const panel = this.renderTabPanel(this.props.tabs[this.getActiveTabIndex()]); + + const tabbedViewClasses = classNames({ + 'mx_TabbedView': true, + 'mx_TabbedView_tabsOnLeft': this.props.tabLocation == TabLocation.LEFT, + 'mx_TabbedView_tabsOnTop': this.props.tabLocation == TabLocation.TOP, + }); return ( -
+
- {labels} + { labels }
- {panel} + { panel }
); } diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx new file mode 100644 index 0000000000..ccf9d9d416 --- /dev/null +++ b/src/components/structures/ThreadPanel.tsx @@ -0,0 +1,93 @@ +/* +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 from 'react'; +import { MatrixEvent, Room } from 'matrix-js-sdk/src'; +import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread'; + +import BaseCard from "../views/right_panel/BaseCard"; +import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import { MatrixClientPeg } from '../../MatrixClientPeg'; + +import ResizeNotifier from '../../utils/ResizeNotifier'; +import EventTile from '../views/rooms/EventTile'; + +interface IProps { + roomId: string; + onClose: () => void; + resizeNotifier: ResizeNotifier; +} + +interface IState { + threads?: Thread[]; +} + +@replaceableComponent("structures.ThreadView") +export default class ThreadPanel extends React.Component { + private room: Room; + + constructor(props: IProps) { + super(props); + this.room = MatrixClientPeg.get().getRoom(this.props.roomId); + } + + public componentDidMount(): void { + this.room.on(ThreadEvent.Update, this.onThreadEventReceived); + this.room.on(ThreadEvent.Ready, this.onThreadEventReceived); + } + + public componentWillUnmount(): void { + this.room.removeListener(ThreadEvent.Update, this.onThreadEventReceived); + this.room.removeListener(ThreadEvent.Ready, this.onThreadEventReceived); + } + + private onThreadEventReceived = () => this.updateThreads(); + + private updateThreads = (callback?: () => void): void => { + this.setState({ + threads: this.room.getThreads(), + }, callback); + }; + + private renderEventTile(event: MatrixEvent): JSX.Element { + return ; + } + + public render(): JSX.Element { + return ( + + { + this.state?.threads.map((thread: Thread) => { + if (thread.ready) { + return this.renderEventTile(thread.rootEvent); + } + }) + } + + ); + } +} diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx new file mode 100644 index 0000000000..8fac538bbc --- /dev/null +++ b/src/components/structures/ThreadView.tsx @@ -0,0 +1,199 @@ +/* +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 from 'react'; +import { MatrixEvent, Room } from 'matrix-js-sdk/src'; +import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread'; + +import BaseCard from "../views/right_panel/BaseCard"; +import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; +import { replaceableComponent } from "../../utils/replaceableComponent"; + +import ResizeNotifier from '../../utils/ResizeNotifier'; +import { TileShape } from '../views/rooms/EventTile'; +import MessageComposer from '../views/rooms/MessageComposer'; +import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks'; +import { Layout } from '../../settings/Layout'; +import TimelinePanel from './TimelinePanel'; +import dis from "../../dispatcher/dispatcher"; +import { ActionPayload } from '../../dispatcher/payloads'; +import { SetRightPanelPhasePayload } from '../../dispatcher/payloads/SetRightPanelPhasePayload'; +import { Action } from '../../dispatcher/actions'; +import { MatrixClientPeg } from '../../MatrixClientPeg'; +import { E2EStatus } from '../../utils/ShieldUtils'; +import EditorStateTransfer from '../../utils/EditorStateTransfer'; +import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext'; + +interface IProps { + room: Room; + onClose: () => void; + resizeNotifier: ResizeNotifier; + mxEvent: MatrixEvent; + permalinkCreator?: RoomPermalinkCreator; + e2eStatus?: E2EStatus; +} + +interface IState { + replyToEvent?: MatrixEvent; + thread?: Thread; + editState?: EditorStateTransfer; + +} + +@replaceableComponent("structures.ThreadView") +export default class ThreadView extends React.Component { + static contextType = RoomContext; + + private dispatcherRef: string; + private timelinePanelRef: React.RefObject = React.createRef(); + + constructor(props: IProps) { + super(props); + this.state = {}; + } + + public componentDidMount(): void { + this.setupThread(this.props.mxEvent); + this.dispatcherRef = dis.register(this.onAction); + } + + public componentWillUnmount(): void { + this.teardownThread(); + dis.unregister(this.dispatcherRef); + } + + public componentDidUpdate(prevProps) { + if (prevProps.mxEvent !== this.props.mxEvent) { + this.teardownThread(); + this.setupThread(this.props.mxEvent); + } + + if (prevProps.room !== this.props.room) { + dis.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.RoomSummary, + }); + } + } + + private onAction = (payload: ActionPayload): void => { + if (payload.phase == RightPanelPhases.ThreadView && payload.event) { + if (payload.event !== this.props.mxEvent) { + this.teardownThread(); + this.setupThread(payload.event); + } + } + switch (payload.action) { + case Action.EditEvent: { + // Quit early if it's not a thread context + if (payload.timelineRenderingType !== TimelineRenderingType.Thread) return; + // Quit early if that's not a thread event + if (payload.event && !payload.event.getThread()) return; + const editState = payload.event ? new EditorStateTransfer(payload.event) : null; + this.setState({ editState }, () => { + if (payload.event) { + this.timelinePanelRef.current?.scrollToEventIfNeeded(payload.event.getId()); + } + }); + break; + } + default: + break; + } + }; + + private setupThread = (mxEv: MatrixEvent) => { + let thread = mxEv.getThread(); + if (!thread) { + const client = MatrixClientPeg.get(); + thread = new Thread([mxEv], this.props.room, client); + mxEv.setThread(thread); + } + thread.on(ThreadEvent.Update, this.updateThread); + thread.once(ThreadEvent.Ready, this.updateThread); + this.updateThread(thread); + }; + + private teardownThread = () => { + if (this.state.thread) { + this.state.thread.removeListener(ThreadEvent.Update, this.updateThread); + this.state.thread.removeListener(ThreadEvent.Ready, this.updateThread); + } + }; + + private updateThread = (thread?: Thread) => { + if (thread) { + this.setState({ + thread, + replyToEvent: thread.replyToEvent, + }); + } + + this.timelinePanelRef.current?.refreshTimeline(); + }; + + public render(): JSX.Element { + return ( + + + + { this.state.thread && ( + empty
} + alwaysShowTimestamps={true} + layout={Layout.Group} + hideThreadedMessages={false} + hidden={false} + showReactions={true} + className="mx_RoomView_messagePanel mx_GroupLayout" + permalinkCreator={this.props.permalinkCreator} + membersLoaded={true} + editState={this.state.editState} + /> + ) } + + { this.state?.thread?.timelineSet && () } + + + ); + } +} diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.tsx similarity index 69% rename from src/components/structures/TimelinePanel.js rename to src/components/structures/TimelinePanel.tsx index 6300c7532e..6924181132 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.tsx @@ -1,8 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2019 New Vector Ltd -Copyright 2019-2020 The Matrix.org Foundation C.I.C. +Copyright 2016 - 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. @@ -17,38 +14,193 @@ See the License for the specific language governing permissions and limitations under the License. */ -import SettingsStore from "../../settings/SettingsStore"; -import {LayoutPropType} from "../../settings/Layout"; -import React, {createRef} from 'react'; +import React, { createRef, ReactNode, SyntheticEvent } from 'react'; import ReactDOM from "react-dom"; -import PropTypes from 'prop-types'; -import {EventTimeline} from "matrix-js-sdk/src/models/event-timeline"; -import {TimelineWindow} from "matrix-js-sdk/src/timeline-window"; +import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { EventTimelineSet } from "matrix-js-sdk/src/models/event-timeline-set"; +import { Direction, EventTimeline } from "matrix-js-sdk/src/models/event-timeline"; +import { TimelineWindow } from "matrix-js-sdk/src/timeline-window"; +import { EventType, RelationType } from 'matrix-js-sdk/src/@types/event'; +import { SyncState } from 'matrix-js-sdk/src/sync.api'; + +import SettingsStore from "../../settings/SettingsStore"; +import { Layout } from "../../settings/Layout"; import { _t } from '../../languageHandler'; -import {MatrixClientPeg} from "../../MatrixClientPeg"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import RoomContext from "../../contexts/RoomContext"; import UserActivity from "../../UserActivity"; import Modal from "../../Modal"; import dis from "../../dispatcher/dispatcher"; -import * as sdk from "../../index"; import { Key } from '../../Keyboard'; import Timer from '../../utils/Timer'; import shouldHideEvent from '../../shouldHideEvent'; -import EditorStateTransfer from '../../utils/EditorStateTransfer'; -import {haveTileForEvent} from "../views/rooms/EventTile"; -import {UIFeature} from "../../settings/UIFeature"; -import {replaceableComponent} from "../../utils/replaceableComponent"; +import { haveTileForEvent, TileShape } from "../views/rooms/EventTile"; +import { UIFeature } from "../../settings/UIFeature"; +import { replaceableComponent } from "../../utils/replaceableComponent"; import { arrayFastClone } from "../../utils/arrays"; +import MessagePanel from "./MessagePanel"; +import { IScrollState } from "./ScrollPanel"; +import { ActionPayload } from "../../dispatcher/payloads"; +import ResizeNotifier from "../../utils/ResizeNotifier"; +import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; +import Spinner from "../views/elements/Spinner"; +import EditorStateTransfer from '../../utils/EditorStateTransfer'; +import ErrorDialog from '../views/dialogs/ErrorDialog'; +import { debounce } from 'lodash'; + +import { logger } from "matrix-js-sdk/src/logger"; const PAGINATE_SIZE = 20; const INITIAL_SIZE = 20; const READ_RECEIPT_INTERVAL_MS = 500; +const READ_MARKER_DEBOUNCE_MS = 100; + const DEBUG = false; -let debuglog = function() {}; +let debuglog = function(...s: any[]) {}; if (DEBUG) { // using bind means that we get to keep useful line numbers in the console - debuglog = console.log.bind(console); + debuglog = logger.log.bind(console); +} + +interface IProps { + // The js-sdk EventTimelineSet object for the timeline sequence we are + // representing. This may or may not have a room, depending on what it's + // a timeline representing. If it has a room, we maintain RRs etc for + // that room. + timelineSet: EventTimelineSet; + showReadReceipts?: boolean; + // Enable managing RRs and RMs. These require the timelineSet to have a room. + manageReadReceipts?: boolean; + sendReadReceiptOnLoad?: boolean; + manageReadMarkers?: boolean; + + // true to give the component a 'display: none' style. + hidden?: boolean; + + // ID of an event to highlight. If undefined, no event will be highlighted. + // typically this will be either 'eventId' or undefined. + highlightedEventId?: string; + + // id of an event to jump to. If not given, will go to the end of the live timeline. + eventId?: string; + + // where to position the event given by eventId, in pixels from the bottom of the viewport. + // If not given, will try to put the event half way down the viewport. + eventPixelOffset?: number; + + // Should we show URL Previews + showUrlPreview?: boolean; + + // maximum number of events to show in a timeline + timelineCap?: number; + + // classname to use for the messagepanel + className?: string; + + // shape property to be passed to EventTiles + tileShape?: TileShape; + + // placeholder to use if the timeline is empty + empty?: ReactNode; + + // whether to show reactions for an event + showReactions?: boolean; + + // which layout to use + layout?: Layout; + + // whether to always show timestamps for an event + alwaysShowTimestamps?: boolean; + + resizeNotifier?: ResizeNotifier; + editState?: EditorStateTransfer; + permalinkCreator?: RoomPermalinkCreator; + membersLoaded?: boolean; + + // callback which is called when the panel is scrolled. + onScroll?(event: Event): void; + + // callback which is called when the user interacts with the room timeline + onUserScroll?(event: SyntheticEvent): void; + + // callback which is called when the read-up-to mark is updated. + onReadMarkerUpdated?(): void; + + // callback which is called when we wish to paginate the timeline window. + onPaginationRequest?(timelineWindow: TimelineWindow, direction: string, size: number): Promise; + + hideThreadedMessages?: boolean; +} + +interface IState { + events: MatrixEvent[]; + liveEvents: MatrixEvent[]; + // track whether our room timeline is loading + timelineLoading: boolean; + + // the index of the first event that is to be shown + firstVisibleEventIndex: number; + + // canBackPaginate == false may mean: + // + // * we haven't (successfully) loaded the timeline yet, or: + // + // * we have got to the point where the room was created, or: + // + // * the server indicated that there were no more visible events + // (normally implying we got to the start of the room), or: + // + // * we gave up asking the server for more events + canBackPaginate: boolean; + + // canForwardPaginate == false may mean: + // + // * we haven't (successfully) loaded the timeline yet + // + // * we have got to the end of time and are now tracking the live + // timeline, or: + // + // * the server indicated that there were no more visible events + // (not sure if this ever happens when we're not at the live + // timeline), or: + // + // * we are looking at some historical point, but gave up asking + // the server for more events + canForwardPaginate: boolean; + + // start with the read-marker visible, so that we see its animated + // disappearance when switching into the room. + readMarkerVisible: boolean; + + readMarkerEventId: string; + + backPaginating: boolean; + forwardPaginating: boolean; + + // cache of matrixClient.getSyncState() (but from the 'sync' event) + clientSyncState: SyncState; + + // should the event tiles have twelve hour times + isTwelveHour: boolean; + + // always show timestamps on event tiles? + alwaysShowTimestamps: boolean; + + // how long to show the RM for when it's visible in the window + readMarkerInViewThresholdMs: number; + + // how long to show the RM for when it's scrolled off-screen + readMarkerOutOfViewThresholdMs: number; + + editState?: EditorStateTransfer; +} + +interface IEventIndexOpts { + ignoreOwn?: boolean; + allowPartial?: boolean; } /* @@ -57,73 +209,11 @@ if (DEBUG) { * Also responsible for handling and sending read receipts. */ @replaceableComponent("structures.TimelinePanel") -class TimelinePanel extends React.Component { - static propTypes = { - // The js-sdk EventTimelineSet object for the timeline sequence we are - // representing. This may or may not have a room, depending on what it's - // a timeline representing. If it has a room, we maintain RRs etc for - // that room. - timelineSet: PropTypes.object.isRequired, - - showReadReceipts: PropTypes.bool, - // Enable managing RRs and RMs. These require the timelineSet to have a room. - manageReadReceipts: PropTypes.bool, - sendReadReceiptOnLoad: PropTypes.bool, - manageReadMarkers: PropTypes.bool, - - // true to give the component a 'display: none' style. - hidden: PropTypes.bool, - - // ID of an event to highlight. If undefined, no event will be highlighted. - // typically this will be either 'eventId' or undefined. - highlightedEventId: PropTypes.string, - - // id of an event to jump to. If not given, will go to the end of the - // live timeline. - eventId: PropTypes.string, - - // where to position the event given by eventId, in pixels from the - // bottom of the viewport. If not given, will try to put the event - // half way down the viewport. - eventPixelOffset: PropTypes.number, - - // Should we show URL Previews - showUrlPreview: PropTypes.bool, - - // callback which is called when the panel is scrolled. - onScroll: PropTypes.func, - - // callback which is called when the user interacts with the room timeline - onUserScroll: PropTypes.func, - - // callback which is called when the read-up-to mark is updated. - onReadMarkerUpdated: PropTypes.func, - - // callback which is called when we wish to paginate the timeline - // window. - onPaginationRequest: PropTypes.func, - - // maximum number of events to show in a timeline - timelineCap: PropTypes.number, - - // classname to use for the messagepanel - className: PropTypes.string, - - // shape property to be passed to EventTiles - tileShape: PropTypes.string, - - // placeholder to use if the timeline is empty - empty: PropTypes.node, - - // whether to show reactions for an event - showReactions: PropTypes.bool, - - // which layout to use - layout: LayoutPropType, - } +class TimelinePanel extends React.Component { + static contextType = RoomContext; // a map from room id to read marker event timestamp - static roomReadMarkerTsMap = {}; + static roomReadMarkerTsMap: Record = {}; static defaultProps = { // By default, disable the timelineCap in favour of unpaginating based on @@ -131,18 +221,24 @@ class TimelinePanel extends React.Component { timelineCap: Number.MAX_VALUE, className: 'mx_RoomView_messagePanel', sendReadReceiptOnLoad: true, + hideThreadedMessages: true, }; - constructor(props) { - super(props); + private lastRRSentEventId: string = undefined; + private lastRMSentEventId: string = undefined; + + private readonly messagePanel = createRef(); + private readonly dispatcherRef: string; + private timelineWindow?: TimelineWindow; + private unmounted = false; + private readReceiptActivityTimer: Timer; + private readMarkerActivityTimer: Timer; + + constructor(props, context) { + super(props, context); debuglog("TimelinePanel: mounting"); - this.lastRRSentEventId = undefined; - this.lastRMSentEventId = undefined; - - this._messagePanel = createRef(); - // XXX: we could track RM per TimelineSet rather than per Room. // but for now we just do it per room for simplicity. let initialReadMarker = null; @@ -151,86 +247,45 @@ class TimelinePanel extends React.Component { if (readmarker) { initialReadMarker = readmarker.getContent().event_id; } else { - initialReadMarker = this._getCurrentReadReceipt(); + initialReadMarker = this.getCurrentReadReceipt(); } } this.state = { events: [], liveEvents: [], - timelineLoading: true, // track whether our room timeline is loading - - // the index of the first event that is to be shown + timelineLoading: true, firstVisibleEventIndex: 0, - - // canBackPaginate == false may mean: - // - // * we haven't (successfully) loaded the timeline yet, or: - // - // * we have got to the point where the room was created, or: - // - // * the server indicated that there were no more visible events - // (normally implying we got to the start of the room), or: - // - // * we gave up asking the server for more events canBackPaginate: false, - - // canForwardPaginate == false may mean: - // - // * we haven't (successfully) loaded the timeline yet - // - // * we have got to the end of time and are now tracking the live - // timeline, or: - // - // * the server indicated that there were no more visible events - // (not sure if this ever happens when we're not at the live - // timeline), or: - // - // * we are looking at some historical point, but gave up asking - // the server for more events canForwardPaginate: false, - - // start with the read-marker visible, so that we see its animated - // disappearance when switching into the room. readMarkerVisible: true, - readMarkerEventId: initialReadMarker, - backPaginating: false, forwardPaginating: false, - - // cache of matrixClient.getSyncState() (but from the 'sync' event) clientSyncState: MatrixClientPeg.get().getSyncState(), - - // should the event tiles have twelve hour times isTwelveHour: SettingsStore.getValue("showTwelveHourTimestamps"), - - // always show timestamps on event tiles? alwaysShowTimestamps: SettingsStore.getValue("alwaysShowTimestamps"), - - // how long to show the RM for when it's visible in the window readMarkerInViewThresholdMs: SettingsStore.getValue("readMarkerInViewThresholdMs"), - - // how long to show the RM for when it's scrolled off-screen readMarkerOutOfViewThresholdMs: SettingsStore.getValue("readMarkerOutOfViewThresholdMs"), }; this.dispatcherRef = dis.register(this.onAction); - MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); - MatrixClientPeg.get().on("Room.timelineReset", this.onRoomTimelineReset); - MatrixClientPeg.get().on("Room.redaction", this.onRoomRedaction); + const cli = MatrixClientPeg.get(); + cli.on("Room.timeline", this.onRoomTimeline); + cli.on("Room.timelineReset", this.onRoomTimelineReset); + cli.on("Room.redaction", this.onRoomRedaction); // same event handler as Room.redaction as for both we just do forceUpdate - MatrixClientPeg.get().on("Room.redactionCancelled", this.onRoomRedaction); - MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt); - MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated); - MatrixClientPeg.get().on("Room.accountData", this.onAccountData); - MatrixClientPeg.get().on("Event.decrypted", this.onEventDecrypted); - MatrixClientPeg.get().on("Event.replaced", this.onEventReplaced); - MatrixClientPeg.get().on("sync", this.onSync); + cli.on("Room.redactionCancelled", this.onRoomRedaction); + cli.on("Room.receipt", this.onRoomReceipt); + cli.on("Room.localEchoUpdated", this.onLocalEchoUpdated); + cli.on("Room.accountData", this.onAccountData); + cli.on("Event.decrypted", this.onEventDecrypted); + cli.on("Event.replaced", this.onEventReplaced); + cli.on("sync", this.onSync); } // TODO: [REACT-WARNING] Move into constructor - // eslint-disable-next-line camelcase + // eslint-disable-next-line UNSAFE_componentWillMount() { if (this.props.manageReadReceipts) { this.updateReadReceiptOnUserActivity(); @@ -239,11 +294,11 @@ class TimelinePanel extends React.Component { this.updateReadMarkerOnUserActivity(); } - this._initTimeline(this.props); + this.initTimeline(this.props); } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - // eslint-disable-next-line camelcase + // eslint-disable-next-line UNSAFE_componentWillReceiveProps(newProps) { if (newProps.timelineSet !== this.props.timelineSet) { // throw new Error("changing timelineSet on a TimelinePanel is not supported"); @@ -263,9 +318,9 @@ class TimelinePanel extends React.Component { const differentEventId = newProps.eventId != this.props.eventId; const differentHighlightedEventId = newProps.highlightedEventId != this.props.highlightedEventId; if (differentEventId || differentHighlightedEventId) { - console.log("TimelinePanel switching to eventId " + newProps.eventId + + logger.log("TimelinePanel switching to eventId " + newProps.eventId + " (was " + this.props.eventId + ")"); - return this._initTimeline(newProps); + return this.initTimeline(newProps); } } @@ -275,13 +330,13 @@ class TimelinePanel extends React.Component { // // (We could use isMounted, but facebook have deprecated that.) this.unmounted = true; - if (this._readReceiptActivityTimer) { - this._readReceiptActivityTimer.abort(); - this._readReceiptActivityTimer = null; + if (this.readReceiptActivityTimer) { + this.readReceiptActivityTimer.abort(); + this.readReceiptActivityTimer = null; } - if (this._readMarkerActivityTimer) { - this._readMarkerActivityTimer.abort(); - this._readMarkerActivityTimer = null; + if (this.readMarkerActivityTimer) { + this.readMarkerActivityTimer.abort(); + this.readMarkerActivityTimer = null; } dis.unregister(this.dispatcherRef); @@ -301,7 +356,7 @@ class TimelinePanel extends React.Component { } } - onMessageListUnfillRequest = (backwards, scrollToken) => { + private onMessageListUnfillRequest = (backwards: boolean, scrollToken: string): void => { // If backwards, unpaginate from the back (i.e. the start of the timeline) const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; debuglog("TimelinePanel: unpaginating events in direction", dir); @@ -320,21 +375,30 @@ class TimelinePanel extends React.Component { if (count > 0) { debuglog("TimelinePanel: Unpaginating", count, "in direction", dir); - this._timelineWindow.unpaginate(count, backwards); + this.timelineWindow.unpaginate(count, backwards); - // We can now paginate in the unpaginated direction - const canPaginateKey = (backwards) ? 'canBackPaginate' : 'canForwardPaginate'; - const { events, liveEvents, firstVisibleEventIndex } = this._getEvents(); - this.setState({ - [canPaginateKey]: true, + const { events, liveEvents, firstVisibleEventIndex } = this.getEvents(); + const newState: Partial = { events, liveEvents, firstVisibleEventIndex, - }); + }; + + // We can now paginate in the unpaginated direction + if (backwards) { + newState.canBackPaginate = true; + } else { + newState.canForwardPaginate = true; + } + this.setState(newState); } }; - onPaginationRequest = (timelineWindow, direction, size) => { + private onPaginationRequest = ( + timelineWindow: TimelineWindow, + direction: Direction, + size: number, + ): Promise => { if (this.props.onPaginationRequest) { return this.props.onPaginationRequest(timelineWindow, direction, size); } else { @@ -343,8 +407,8 @@ class TimelinePanel extends React.Component { }; // set off a pagination request. - onMessageListFillRequest = backwards => { - if (!this._shouldPaginate()) return Promise.resolve(false); + private onMessageListFillRequest = (backwards: boolean): Promise => { + if (!this.shouldPaginate()) return Promise.resolve(false); const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; const canPaginateKey = backwards ? 'canBackPaginate' : 'canForwardPaginate'; @@ -355,9 +419,9 @@ class TimelinePanel extends React.Component { return Promise.resolve(false); } - if (!this._timelineWindow.canPaginate(dir)) { + if (!this.timelineWindow.canPaginate(dir)) { debuglog("TimelinePanel: can't", dir, "paginate any further"); - this.setState({[canPaginateKey]: false}); + this.setState({ [canPaginateKey]: false }); return Promise.resolve(false); } @@ -367,15 +431,15 @@ class TimelinePanel extends React.Component { } debuglog("TimelinePanel: Initiating paginate; backwards:"+backwards); - this.setState({[paginatingKey]: true}); + this.setState({ [paginatingKey]: true }); - return this.onPaginationRequest(this._timelineWindow, dir, PAGINATE_SIZE).then((r) => { + return this.onPaginationRequest(this.timelineWindow, dir, PAGINATE_SIZE).then((r) => { if (this.unmounted) { return; } debuglog("TimelinePanel: paginate complete backwards:"+backwards+"; success:"+r); - const { events, liveEvents, firstVisibleEventIndex } = this._getEvents(); - const newState = { + const { events, liveEvents, firstVisibleEventIndex } = this.getEvents(); + const newState: Partial = { [paginatingKey]: false, [canPaginateKey]: r, events, @@ -388,7 +452,7 @@ class TimelinePanel extends React.Component { const otherDirection = backwards ? EventTimeline.FORWARDS : EventTimeline.BACKWARDS; const canPaginateOtherWayKey = backwards ? 'canForwardPaginate' : 'canBackPaginate'; if (!this.state[canPaginateOtherWayKey] && - this._timelineWindow.canPaginate(otherDirection)) { + this.timelineWindow.canPaginate(otherDirection)) { debuglog('TimelinePanel: can now', otherDirection, 'paginate again'); newState[canPaginateOtherWayKey] = true; } @@ -399,9 +463,9 @@ class TimelinePanel extends React.Component { // has in memory because we never gave the component a chance to scroll // itself into the right place return new Promise((resolve) => { - this.setState(newState, () => { + this.setState(newState, () => { // we can continue paginating in the given direction if: - // - _timelineWindow.paginate says we can + // - timelineWindow.paginate says we can // - we're paginating forwards, or we won't be trying to // paginate backwards past the first visible event resolve(r && (!backwards || firstVisibleEventIndex === 0)); @@ -410,48 +474,59 @@ class TimelinePanel extends React.Component { }); }; - onMessageListScroll = e => { + private onMessageListScroll = e => { if (this.props.onScroll) { this.props.onScroll(e); } if (this.props.manageReadMarkers) { - const rmPosition = this.getReadMarkerPosition(); - // we hide the read marker when it first comes onto the screen, but if - // it goes back off the top of the screen (presumably because the user - // clicks on the 'jump to bottom' button), we need to re-enable it. - if (rmPosition < 0) { - this.setState({readMarkerVisible: true}); - } - - // if read marker position goes between 0 and -1/1, - // (and user is active), switch timeout - const timeout = this._readMarkerTimeout(rmPosition); - // NO-OP when timeout already has set to the given value - this._readMarkerActivityTimer.changeTimeout(timeout); + this.doManageReadMarkers(); } }; - onAction = payload => { - if (payload.action === 'ignore_state_changed') { - this.forceUpdate(); + /* + * Debounced function to manage read markers because we don't need to + * do this on every tiny scroll update. It also sets state which causes + * a component update, which can in turn reset the scroll position, so + * it's important we allow the browser to scroll a bit before running this + * (hence trailing edge only and debounce rather than throttle because + * we really only need to update this once the user has finished scrolling, + * not periodically while they scroll). + */ + private doManageReadMarkers = debounce(() => { + const rmPosition = this.getReadMarkerPosition(); + // we hide the read marker when it first comes onto the screen, but if + // it goes back off the top of the screen (presumably because the user + // clicks on the 'jump to bottom' button), we need to re-enable it. + if (rmPosition < 0) { + this.setState({ readMarkerVisible: true }); } - if (payload.action === "edit_event") { - const editState = payload.event ? new EditorStateTransfer(payload.event) : null; - this.setState({editState}, () => { - if (payload.event && this._messagePanel.current) { - this._messagePanel.current.scrollToEventIfNeeded( - payload.event.getId(), - ); - } - }); - } - if (payload.action === "scroll_to_bottom") { - this.jumpToLiveTimeline(); + + // if read marker position goes between 0 and -1/1, + // (and user is active), switch timeout + const timeout = this.readMarkerTimeout(rmPosition); + // NO-OP when timeout already has set to the given value + this.readMarkerActivityTimer.changeTimeout(timeout); + }, READ_MARKER_DEBOUNCE_MS, { leading: false, trailing: true }); + + private onAction = (payload: ActionPayload): void => { + switch (payload.action) { + case "ignore_state_changed": + this.forceUpdate(); + break; } }; - onRoomTimeline = (ev, room, toStartOfTimeline, removed, data) => { + private onRoomTimeline = ( + ev: MatrixEvent, + room: Room, + toStartOfTimeline: boolean, + removed: boolean, + data: { + timeline: EventTimeline; + liveEvent?: boolean; + }, + ): void => { // ignore events for other timeline sets if (data.timeline.getTimelineSet() !== this.props.timelineSet) return; @@ -459,13 +534,13 @@ class TimelinePanel extends React.Component { // updates from pagination will happen when the paginate completes. if (toStartOfTimeline || !data || !data.liveEvent) return; - if (!this._messagePanel.current) return; + if (!this.messagePanel.current) return; - if (!this._messagePanel.current.getScrollState().stuckAtBottom) { + if (!this.messagePanel.current.getScrollState().stuckAtBottom) { // we won't load this event now, because we don't want to push any // events off the other end of the timeline. But we need to note // that we can now paginate. - this.setState({canForwardPaginate: true}); + this.setState({ canForwardPaginate: true }); return; } @@ -478,13 +553,13 @@ class TimelinePanel extends React.Component { // timeline window. // // see https://github.com/vector-im/vector-web/issues/1035 - this._timelineWindow.paginate(EventTimeline.FORWARDS, 1, false).then(() => { + this.timelineWindow.paginate(EventTimeline.FORWARDS, 1, false).then(() => { if (this.unmounted) { return; } - const { events, liveEvents, firstVisibleEventIndex } = this._getEvents(); + const { events, liveEvents, firstVisibleEventIndex } = this.getEvents(); const lastLiveEvent = liveEvents[liveEvents.length - 1]; - const updatedState = { + const updatedState: Partial = { events, liveEvents, firstVisibleEventIndex, @@ -501,23 +576,22 @@ class TimelinePanel extends React.Component { // more than the timeout on userActiveRecently. // const myUserId = MatrixClientPeg.get().credentials.userId; - const sender = ev.sender ? ev.sender.userId : null; callRMUpdated = false; - if (sender != myUserId && !UserActivity.sharedInstance().userActiveRecently()) { + if (ev.getSender() !== myUserId && !UserActivity.sharedInstance().userActiveRecently()) { updatedState.readMarkerVisible = true; } else if (lastLiveEvent && this.getReadMarkerPosition() === 0) { // we know we're stuckAtBottom, so we can advance the RM // immediately, to save a later render cycle - this._setReadMarker(lastLiveEvent.getId(), lastLiveEvent.getTs(), true); + this.setReadMarker(lastLiveEvent.getId(), lastLiveEvent.getTs(), true); updatedState.readMarkerVisible = false; updatedState.readMarkerEventId = lastLiveEvent.getId(); callRMUpdated = true; } } - this.setState(updatedState, () => { - this._messagePanel.current.updateTimelineMinHeight(); + this.setState(updatedState, () => { + this.messagePanel.current.updateTimelineMinHeight(); if (callRMUpdated) { this.props.onReadMarkerUpdated(); } @@ -525,17 +599,17 @@ class TimelinePanel extends React.Component { }); }; - onRoomTimelineReset = (room, timelineSet) => { + private onRoomTimelineReset = (room: Room, timelineSet: EventTimelineSet): void => { if (timelineSet !== this.props.timelineSet) return; - if (this._messagePanel.current && this._messagePanel.current.isAtBottom()) { - this._loadTimeline(); + if (this.messagePanel.current && this.messagePanel.current.isAtBottom()) { + this.loadTimeline(); } }; - canResetTimeline = () => this._messagePanel.current && this._messagePanel.current.isAtBottom(); + public canResetTimeline = () => this.messagePanel?.current.isAtBottom(); - onRoomRedaction = (ev, room) => { + private onRoomRedaction = (ev: MatrixEvent, room: Room): void => { if (this.unmounted) return; // ignore events for other rooms @@ -546,7 +620,7 @@ class TimelinePanel extends React.Component { this.forceUpdate(); }; - onEventReplaced = (replacedEvent, room) => { + private onEventReplaced = (replacedEvent: MatrixEvent, room: Room): void => { if (this.unmounted) return; // ignore events for other rooms @@ -557,7 +631,7 @@ class TimelinePanel extends React.Component { this.forceUpdate(); }; - onRoomReceipt = (ev, room) => { + private onRoomReceipt = (ev: MatrixEvent, room: Room): void => { if (this.unmounted) return; // ignore events for other rooms @@ -566,22 +640,22 @@ class TimelinePanel extends React.Component { this.forceUpdate(); }; - onLocalEchoUpdated = (ev, room, oldEventId) => { + private onLocalEchoUpdated = (ev: MatrixEvent, room: Room, oldEventId: string): void => { if (this.unmounted) return; // ignore events for other rooms if (room !== this.props.timelineSet.room) return; - this._reloadEvents(); + this.reloadEvents(); }; - onAccountData = (ev, room) => { + private onAccountData = (ev: MatrixEvent, room: Room): void => { if (this.unmounted) return; // ignore events for other rooms if (room !== this.props.timelineSet.room) return; - if (ev.getType() !== "m.fully_read") return; + if (ev.getType() !== EventType.FullyRead) return; // XXX: roomReadMarkerTsMap not updated here so it is now inconsistent. Replace // this mechanism of determining where the RM is relative to the view-port with @@ -591,7 +665,7 @@ class TimelinePanel extends React.Component { }, this.props.onReadMarkerUpdated); }; - onEventDecrypted = ev => { + private onEventDecrypted = (ev: MatrixEvent): void => { // Can be null for the notification timeline, etc. if (!this.props.timelineSet.room) return; @@ -606,46 +680,46 @@ class TimelinePanel extends React.Component { } }; - onSync = (state, prevState, data) => { - this.setState({clientSyncState: state}); + private onSync = (clientSyncState: SyncState, prevState: SyncState, data: object): void => { + this.setState({ clientSyncState }); }; - _readMarkerTimeout(readMarkerPosition) { + private readMarkerTimeout(readMarkerPosition: number): number { return readMarkerPosition === 0 ? - this.state.readMarkerInViewThresholdMs : - this.state.readMarkerOutOfViewThresholdMs; + this.context?.readMarkerInViewThresholdMs ?? this.state.readMarkerInViewThresholdMs : + this.context?.readMarkerOutOfViewThresholdMs ?? this.state.readMarkerOutOfViewThresholdMs; } - async updateReadMarkerOnUserActivity() { - const initialTimeout = this._readMarkerTimeout(this.getReadMarkerPosition()); - this._readMarkerActivityTimer = new Timer(initialTimeout); + private async updateReadMarkerOnUserActivity(): Promise { + const initialTimeout = this.readMarkerTimeout(this.getReadMarkerPosition()); + this.readMarkerActivityTimer = new Timer(initialTimeout); - while (this._readMarkerActivityTimer) { //unset on unmount - UserActivity.sharedInstance().timeWhileActiveRecently(this._readMarkerActivityTimer); + while (this.readMarkerActivityTimer) { //unset on unmount + UserActivity.sharedInstance().timeWhileActiveRecently(this.readMarkerActivityTimer); try { - await this._readMarkerActivityTimer.finished(); + await this.readMarkerActivityTimer.finished(); } catch (e) { continue; /* aborted */ } // outside of try/catch to not swallow errors this.updateReadMarker(); } } - async updateReadReceiptOnUserActivity() { - this._readReceiptActivityTimer = new Timer(READ_RECEIPT_INTERVAL_MS); - while (this._readReceiptActivityTimer) { //unset on unmount - UserActivity.sharedInstance().timeWhileActiveNow(this._readReceiptActivityTimer); + private async updateReadReceiptOnUserActivity(): Promise { + this.readReceiptActivityTimer = new Timer(READ_RECEIPT_INTERVAL_MS); + while (this.readReceiptActivityTimer) { //unset on unmount + UserActivity.sharedInstance().timeWhileActiveNow(this.readReceiptActivityTimer); try { - await this._readReceiptActivityTimer.finished(); + await this.readReceiptActivityTimer.finished(); } catch (e) { continue; /* aborted */ } // outside of try/catch to not swallow errors this.sendReadReceipt(); } } - sendReadReceipt = () => { + private sendReadReceipt = (): void => { if (SettingsStore.getValue("lowBandwidth")) return; - if (!this._messagePanel.current) return; + if (!this.messagePanel.current) return; if (!this.props.manageReadReceipts) return; // This happens on user_activity_end which is delayed, and it's // very possible have logged out within that timeframe, so check @@ -656,8 +730,8 @@ class TimelinePanel extends React.Component { let shouldSendRR = true; - const currentRREventId = this._getCurrentReadReceipt(true); - const currentRREventIndex = this._indexForEventId(currentRREventId); + const currentRREventId = this.getCurrentReadReceipt(true); + const currentRREventIndex = this.indexForEventId(currentRREventId); // We want to avoid sending out read receipts when we are looking at // events in the past which are before the latest RR. // @@ -672,11 +746,11 @@ class TimelinePanel extends React.Component { // the user eventually hits the live timeline. // if (currentRREventId && currentRREventIndex === null && - this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { + this.timelineWindow.canPaginate(EventTimeline.FORWARDS)) { shouldSendRR = false; } - const lastReadEventIndex = this._getLastDisplayedEventIndex({ + const lastReadEventIndex = this.getLastDisplayedEventIndex({ ignoreOwn: true, }); if (lastReadEventIndex === null) { @@ -704,16 +778,20 @@ class TimelinePanel extends React.Component { } this.lastRMSentEventId = this.state.readMarkerEventId; + const roomId = this.props.timelineSet.room.roomId; + const hiddenRR = SettingsStore.getValue("feature_hidden_read_receipts", roomId); + debuglog('TimelinePanel: Sending Read Markers for ', this.props.timelineSet.room.roomId, 'rm', this.state.readMarkerEventId, lastReadEvent ? 'rr ' + lastReadEvent.getId() : '', + ' hidden:' + hiddenRR, ); MatrixClientPeg.get().setRoomReadMarkers( - this.props.timelineSet.room.roomId, + roomId, this.state.readMarkerEventId, lastReadEvent, // Could be null, in which case no RR is sent - {}, + { hidden: hiddenRR }, ).catch((e) => { // /read_markers API is not implemented on this HS, fallback to just RR if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) { @@ -738,8 +816,8 @@ class TimelinePanel extends React.Component { // that sending an RR for the latest message will set our notif counter // to zero: it may not do this if we send an RR for somewhere before the end. if (this.isAtEndOfLiveTimeline()) { - this.props.timelineSet.room.setUnreadNotificationCount('total', 0); - this.props.timelineSet.room.setUnreadNotificationCount('highlight', 0); + this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Total, 0); + this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Highlight, 0); dis.dispatch({ action: 'on_room_read', roomId: this.props.timelineSet.room.roomId, @@ -750,7 +828,7 @@ class TimelinePanel extends React.Component { // if the read marker is on the screen, we can now assume we've caught up to the end // of the screen, so move the marker down to the bottom of the screen. - updateReadMarker = () => { + private updateReadMarker = (): void => { if (!this.props.manageReadMarkers) return; if (this.getReadMarkerPosition() === 1) { // the read marker is at an event below the viewport, @@ -760,7 +838,7 @@ class TimelinePanel extends React.Component { // move the RM to *after* the message at the bottom of the screen. This // avoids a problem whereby we never advance the RM if there is a huge // message which doesn't fit on the screen. - const lastDisplayedIndex = this._getLastDisplayedEventIndex({ + const lastDisplayedIndex = this.getLastDisplayedEventIndex({ allowPartial: true, }); @@ -768,7 +846,7 @@ class TimelinePanel extends React.Component { return; } const lastDisplayedEvent = this.state.events[lastDisplayedIndex]; - this._setReadMarker( + this.setReadMarker( lastDisplayedEvent.getId(), lastDisplayedEvent.getTs(), ); @@ -785,15 +863,14 @@ class TimelinePanel extends React.Component { this.sendReadReceipt(); }; - // advance the read marker past any events we sent ourselves. - _advanceReadMarkerPastMyEvents() { + private advanceReadMarkerPastMyEvents(): void { if (!this.props.manageReadMarkers) return; - // we call `_timelineWindow.getEvents()` rather than using + // we call `timelineWindow.getEvents()` rather than using // `this.state.liveEvents`, because React batches the update to the // latter, so it may not have been updated yet. - const events = this._timelineWindow.getEvents(); + const events = this.timelineWindow.getEvents(); // first find where the current RM is let i; @@ -810,7 +887,7 @@ class TimelinePanel extends React.Component { const myUserId = MatrixClientPeg.get().credentials.userId; for (i++; i < events.length; i++) { const ev = events[i]; - if (!ev.sender || ev.sender.userId != myUserId) { + if (ev.getSender() !== myUserId) { break; } } @@ -818,45 +895,47 @@ class TimelinePanel extends React.Component { i--; const ev = events[i]; - this._setReadMarker(ev.getId(), ev.getTs()); + this.setReadMarker(ev.getId(), ev.getTs()); } /* jump down to the bottom of this room, where new events are arriving */ - jumpToLiveTimeline = () => { + public jumpToLiveTimeline = (): void => { // if we can't forward-paginate the existing timeline, then there // is no point reloading it - just jump straight to the bottom. // // Otherwise, reload the timeline rather than trying to paginate // through all of space-time. - if (this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { - this._loadTimeline(); + if (this.timelineWindow.canPaginate(EventTimeline.FORWARDS)) { + this.loadTimeline(); } else { - if (this._messagePanel.current) { - this._messagePanel.current.scrollToBottom(); - } + this.messagePanel.current?.scrollToBottom(); } }; + public scrollToEventIfNeeded = (eventId: string): void => { + this.messagePanel.current?.scrollToEventIfNeeded(eventId); + }; + /* scroll to show the read-up-to marker. We put it 1/3 of the way down * the container. */ - jumpToReadMarker = () => { + public jumpToReadMarker = (): void => { if (!this.props.manageReadMarkers) return; - if (!this._messagePanel.current) return; + if (!this.messagePanel.current) return; if (!this.state.readMarkerEventId) return; // we may not have loaded the event corresponding to the read-marker - // into the _timelineWindow. In that case, attempts to scroll to it + // into the timelineWindow. In that case, attempts to scroll to it // will fail. // // a quick way to figure out if we've loaded the relevant event is // simply to check if the messagepanel knows where the read-marker is. - const ret = this._messagePanel.current.getReadMarkerPosition(); + const ret = this.messagePanel.current.getReadMarkerPosition(); if (ret !== null) { // The messagepanel knows where the RM is, so we must have loaded // the relevant event. - this._messagePanel.current.scrollToEvent(this.state.readMarkerEventId, + this.messagePanel.current.scrollToEvent(this.state.readMarkerEventId, 0, 1/3); return; } @@ -864,15 +943,15 @@ class TimelinePanel extends React.Component { // Looks like we haven't loaded the event corresponding to the read-marker. // As with jumpToLiveTimeline, we want to reload the timeline around the // read-marker. - this._loadTimeline(this.state.readMarkerEventId, 0, 1/3); + this.loadTimeline(this.state.readMarkerEventId, 0, 1/3); }; /* update the read-up-to marker to match the read receipt */ - forgetReadMarker = () => { + public forgetReadMarker = (): void => { if (!this.props.manageReadMarkers) return; - const rmId = this._getCurrentReadReceipt(); + const rmId = this.getCurrentReadReceipt(); // see if we know the timestamp for the rr event const tl = this.props.timelineSet.getTimelineForEvent(rmId); @@ -884,28 +963,26 @@ class TimelinePanel extends React.Component { } } - this._setReadMarker(rmId, rmTs); + this.setReadMarker(rmId, rmTs); }; /* return true if the content is fully scrolled down and we are * at the end of the live timeline. */ - isAtEndOfLiveTimeline = () => { - return this._messagePanel.current - && this._messagePanel.current.isAtBottom() - && this._timelineWindow - && !this._timelineWindow.canPaginate(EventTimeline.FORWARDS); - } - + public isAtEndOfLiveTimeline = (): boolean => { + return this.messagePanel.current?.isAtBottom() + && this.timelineWindow + && !this.timelineWindow.canPaginate(EventTimeline.FORWARDS); + }; /* get the current scroll state. See ScrollPanel.getScrollState for * details. * * returns null if we are not mounted. */ - getScrollState = () => { - if (!this._messagePanel.current) { return null; } - return this._messagePanel.current.getScrollState(); + public getScrollState = (): IScrollState => { + if (!this.messagePanel.current) { return null; } + return this.messagePanel.current.getScrollState(); }; // returns one of: @@ -914,11 +991,11 @@ class TimelinePanel extends React.Component { // -1: read marker is above the window // 0: read marker is visible // +1: read marker is below the window - getReadMarkerPosition = () => { + public getReadMarkerPosition = (): number => { if (!this.props.manageReadMarkers) return null; - if (!this._messagePanel.current) return null; + if (!this.messagePanel.current) return null; - const ret = this._messagePanel.current.getReadMarkerPosition(); + const ret = this.messagePanel.current.getReadMarkerPosition(); if (ret !== null) { return ret; } @@ -937,7 +1014,7 @@ class TimelinePanel extends React.Component { return null; }; - canJumpToReadMarker = () => { + public canJumpToReadMarker = (): boolean => { // 1. Do not show jump bar if neither the RM nor the RR are set. // 3. We want to show the bar if the read-marker is off the top of the screen. // 4. Also, if pos === null, the event might not be paginated - show the unread bar @@ -952,19 +1029,19 @@ class TimelinePanel extends React.Component { * * We pass it down to the scroll panel. */ - handleScrollKey = ev => { - if (!this._messagePanel.current) { return; } + public handleScrollKey = ev => { + if (!this.messagePanel.current) { return; } // jump to the live timeline on ctrl-end, rather than the end of the // timeline window. if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey && ev.key === Key.END) { this.jumpToLiveTimeline(); } else { - this._messagePanel.current.handleScrollKey(ev); + this.messagePanel.current.handleScrollKey(ev); } }; - _initTimeline(props) { + private initTimeline(props: IProps): void { const initialEvent = props.eventId; const pixelOffset = props.eventPixelOffset; @@ -975,7 +1052,7 @@ class TimelinePanel extends React.Component { offsetBase = 0.5; } - return this._loadTimeline(initialEvent, pixelOffset, offsetBase); + return this.loadTimeline(initialEvent, pixelOffset, offsetBase); } /** @@ -991,47 +1068,47 @@ class TimelinePanel extends React.Component { * @param {number?} offsetBase the reference point for the pixelOffset. 0 * means the top of the container, 1 means the bottom, and fractional * values mean somewhere in the middle. If omitted, it defaults to 0. - * - * returns a promise which will resolve when the load completes. */ - _loadTimeline(eventId, pixelOffset, offsetBase) { - this._timelineWindow = new TimelineWindow( + private loadTimeline(eventId?: string, pixelOffset?: number, offsetBase?: number): void { + this.timelineWindow = new TimelineWindow( MatrixClientPeg.get(), this.props.timelineSet, - {windowLimit: this.props.timelineCap}); + { windowLimit: this.props.timelineCap }); const onLoaded = () => { + if (this.unmounted) return; + // clear the timeline min-height when // (re)loading the timeline - if (this._messagePanel.current) { - this._messagePanel.current.onTimelineReset(); + if (this.messagePanel.current) { + this.messagePanel.current.onTimelineReset(); } - this._reloadEvents(); + this.reloadEvents(); // If we switched away from the room while there were pending // outgoing events, the read-marker will be before those events. // We need to skip over any which have subsequently been sent. - this._advanceReadMarkerPastMyEvents(); + this.advanceReadMarkerPastMyEvents(); this.setState({ - canBackPaginate: this._timelineWindow.canPaginate(EventTimeline.BACKWARDS), - canForwardPaginate: this._timelineWindow.canPaginate(EventTimeline.FORWARDS), + canBackPaginate: this.timelineWindow.canPaginate(EventTimeline.BACKWARDS), + canForwardPaginate: this.timelineWindow.canPaginate(EventTimeline.FORWARDS), timelineLoading: false, }, () => { // initialise the scroll state of the message panel - if (!this._messagePanel.current) { + if (!this.messagePanel.current) { // this shouldn't happen - we know we're mounted because // we're in a setState callback, and we know // timelineLoading is now false, so render() should have // mounted the message panel. - console.log("can't initialise scroll state because " + + logger.log("can't initialise scroll state because " + "messagePanel didn't load"); return; } if (eventId) { - this._messagePanel.current.scrollToEvent(eventId, pixelOffset, + this.messagePanel.current.scrollToEvent(eventId, pixelOffset, offsetBase); } else { - this._messagePanel.current.scrollToBottom(); + this.messagePanel.current.scrollToBottom(); } if (this.props.sendReadReceiptOnLoad) { @@ -1041,11 +1118,12 @@ class TimelinePanel extends React.Component { }; const onError = (error) => { + if (this.unmounted) return; + this.setState({ timelineLoading: false }); console.error( `Error loading timeline panel at ${eventId}: ${error}`, ); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); let onFinished; @@ -1093,10 +1171,10 @@ class TimelinePanel extends React.Component { if (timeline) { // This is a hot-path optimization by skipping a promise tick // by repeating a no-op sync branch in TimelineSet.getTimelineForEvent & MatrixClient.getEventTimeline - this._timelineWindow.load(eventId, INITIAL_SIZE); // in this branch this method will happen in sync time + this.timelineWindow.load(eventId, INITIAL_SIZE); // in this branch this method will happen in sync time onLoaded(); } else { - const prom = this._timelineWindow.load(eventId, INITIAL_SIZE); + const prom = this.timelineWindow.load(eventId, INITIAL_SIZE); this.setState({ events: [], liveEvents: [], @@ -1111,17 +1189,23 @@ class TimelinePanel extends React.Component { // handle the completion of a timeline load or localEchoUpdate, by // reloading the events from the timelinewindow and pending event list into // the state. - _reloadEvents() { + private reloadEvents(): void { // we might have switched rooms since the load started - just bin // the results if so. if (this.unmounted) return; - this.setState(this._getEvents()); + this.setState(this.getEvents()); + } + + // Force refresh the timeline before threads support pending events + public refreshTimeline(): void { + this.loadTimeline(); + this.reloadEvents(); } // get the list of events from the timeline window and the pending event list - _getEvents() { - const events = this._timelineWindow.getEvents(); + private getEvents(): Pick { + const events: MatrixEvent[] = this.timelineWindow.getEvents(); // `arrayFastClone` performs a shallow copy of the array // we want the last event to be decrypted first but displayed last @@ -1133,14 +1217,14 @@ class TimelinePanel extends React.Component { client.decryptEventIfNeeded(event); }); - const firstVisibleEventIndex = this._checkForPreJoinUISI(events); + const firstVisibleEventIndex = this.checkForPreJoinUISI(events); // Hold onto the live events separately. The read receipt and read marker // should use this list, so that they don't advance into pending events. const liveEvents = [...events]; // if we're at the end of the live timeline, append the pending events - if (!this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { + if (!this.timelineWindow.canPaginate(EventTimeline.FORWARDS)) { events.push(...this.props.timelineSet.getPendingEvents()); } @@ -1161,7 +1245,7 @@ class TimelinePanel extends React.Component { * undecryptable event that was sent while the user was not in the room. If no * such events were found, then it returns 0. */ - _checkForPreJoinUISI(events) { + private checkForPreJoinUISI(events: MatrixEvent[]): number { const room = this.props.timelineSet.room; if (events.length === 0 || !room || @@ -1225,7 +1309,7 @@ class TimelinePanel extends React.Component { return 0; } - _indexForEventId(evId) { + private indexForEventId(evId: string): number | null { for (let i = 0; i < this.state.events.length; ++i) { if (evId == this.state.events[i].getId()) { return i; @@ -1234,15 +1318,14 @@ class TimelinePanel extends React.Component { return null; } - _getLastDisplayedEventIndex(opts) { - opts = opts || {}; + private getLastDisplayedEventIndex(opts: IEventIndexOpts = {}): number | null { const ignoreOwn = opts.ignoreOwn || false; const allowPartial = opts.allowPartial || false; - const messagePanel = this._messagePanel.current; + const messagePanel = this.messagePanel.current; if (!messagePanel) return null; - const messagePanelNode = ReactDOM.findDOMNode(messagePanel); + const messagePanelNode = ReactDOM.findDOMNode(messagePanel) as HTMLElement; if (!messagePanelNode) return null; // sometimes this happens for fresh rooms/post-sync const wrapperRect = messagePanelNode.getBoundingClientRect(); const myUserId = MatrixClientPeg.get().credentials.userId; @@ -1284,8 +1367,9 @@ class TimelinePanel extends React.Component { } const shouldIgnore = !!ev.status || // local echo - (ignoreOwn && ev.sender && ev.sender.userId == myUserId); // own message - const isWithoutTile = !haveTileForEvent(ev) || shouldHideEvent(ev); + (ignoreOwn && ev.getSender() === myUserId); // own message + const isWithoutTile = !haveTileForEvent(ev, this.context?.showHiddenEventsInTimeline) || + shouldHideEvent(ev, this.context); if (isWithoutTile || !node) { // don't start counting if the event should be ignored, @@ -1319,7 +1403,7 @@ class TimelinePanel extends React.Component { * SDK. * @return {String} the event ID */ - _getCurrentReadReceipt(ignoreSynthesized) { + private getCurrentReadReceipt(ignoreSynthesized = false): string { const client = MatrixClientPeg.get(); // the client can be null on logout if (client == null) { @@ -1330,7 +1414,7 @@ class TimelinePanel extends React.Component { return this.props.timelineSet.room.getEventReadUpTo(myUserId, ignoreSynthesized); } - _setReadMarker(eventId, eventTs, inhibitSetState) { + private setReadMarker(eventId: string, eventTs: number, inhibitSetState = false): void { const roomId = this.props.timelineSet.room.roomId; // don't update the state (and cause a re-render) if there is @@ -1355,7 +1439,7 @@ class TimelinePanel extends React.Component { }, this.props.onReadMarkerUpdated); } - _shouldPaginate() { + private shouldPaginate(): boolean { // don't try to paginate while events in the timeline are // still being decrypted. We don't render events while they're // being decrypted, so they don't take up space in the timeline. @@ -1366,12 +1450,13 @@ class TimelinePanel extends React.Component { }); } - getRelationsForEvent = (...args) => this.props.timelineSet.getRelationsForEvent(...args); + private getRelationsForEvent = ( + eventId: string, + relationType: RelationType, + eventType: EventType | string, + ) => this.props.timelineSet.getRelationsForEvent(eventId, relationType, eventType); render() { - const MessagePanel = sdk.getComponent("structures.MessagePanel"); - const Loader = sdk.getComponent("elements.Spinner"); - // just show a spinner while the timeline loads. // // put it in a div of the right class (mx_RoomView_messagePanel) so @@ -1386,7 +1471,7 @@ class TimelinePanel extends React.Component { if (this.state.timelineLoading) { return (
- +
); } @@ -1394,7 +1479,7 @@ class TimelinePanel extends React.Component { if (this.state.events.length == 0 && !this.state.canBackPaginate && this.props.empty) { return (
-
{this.props.empty}
+
{ this.props.empty }
); } @@ -1407,7 +1492,7 @@ class TimelinePanel extends React.Component { // forwards, otherwise if somebody hits the bottom of the loaded // events when viewing historical messages, we get stuck in a loop // of paginating our way through the entire history of the room. - const stickyBottom = !this._timelineWindow.canPaginate(EventTimeline.FORWARDS); + const stickyBottom = !this.timelineWindow.canPaginate(EventTimeline.FORWARDS); // If the state is PREPARED or CATCHUP, we're still waiting for the js-sdk to sync with // the HS and fetch the latest events, so we are effectively forward paginating. @@ -1420,7 +1505,7 @@ class TimelinePanel extends React.Component { : this.state.events; return (
; } else if (SettingsStore.getValue(UIFeature.Registration)) { footer = ( - {_t("New? Create account", {}, { + { _t("New? Create account", {}, { a: sub => { sub }, - })} + }) } ); } @@ -597,8 +600,8 @@ export default class LoginComponent extends React.PureComponent

- {_t('Sign in')} - {loader} + { _t('Sign in') } + { loader }

{ errorTextSection } { serverDeadSection } diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx index 6feb1e34f7..4cffed4348 100644 --- a/src/components/structures/auth/Registration.tsx +++ b/src/components/structures/auth/Registration.tsx @@ -14,23 +14,30 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {createClient} from 'matrix-js-sdk/src/matrix'; -import React, {ReactNode} from 'react'; -import {MatrixClient} from "matrix-js-sdk/src/client"; +import { createClient } from 'matrix-js-sdk/src/matrix'; +import React, { ReactNode } from 'react'; +import { MatrixClient } from "matrix-js-sdk/src/client"; -import * as sdk from '../../../index'; import { _t, _td } from '../../../languageHandler'; import { messageForResourceLimitError } from '../../../utils/ErrorUtils'; -import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; +import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils"; import classNames from "classnames"; import * as Lifecycle from '../../../Lifecycle'; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; +import { IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg"; import AuthPage from "../../views/auth/AuthPage"; -import Login, {ISSOFlow} from "../../../Login"; +import Login, { ISSOFlow } from "../../../Login"; import dis from "../../../dispatcher/dispatcher"; import SSOButtons from "../../views/elements/SSOButtons"; import ServerPicker from '../../views/elements/ServerPicker'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import RegistrationForm from '../../views/auth/RegistrationForm'; +import AccessibleButton from '../../views/elements/AccessibleButton'; +import AuthBody from "../../views/auth/AuthBody"; +import AuthHeader from "../../views/auth/AuthHeader"; +import InteractiveAuth from "../InteractiveAuth"; +import Spinner from "../../views/elements/Spinner"; + +import { logger } from "matrix-js-sdk/src/logger"; interface IProps { serverConfig: ValidatedServerConfig; @@ -47,13 +54,7 @@ interface IProps { // - The user's password, if available and applicable (may be cached in memory // for a short time so the user is not required to re-enter their password // for operations like uploading cross-signing keys). - onLoggedIn(params: { - userId: string; - deviceId: string - homeserverUrl: string; - identityServerUrl?: string; - accessToken: string; - }, password: string): void; + onLoggedIn(params: IMatrixClientCreds, password: string): void; makeRegistrationUrl(params: { /* eslint-disable camelcase */ client_secret: string; @@ -131,7 +132,7 @@ export default class Registration extends React.Component { serverDeadError: "", }; - const {hsUrl, isUrl} = this.props.serverConfig; + const { hsUrl, isUrl } = this.props.serverConfig; this.loginLogic = new Login(hsUrl, isUrl, null, { defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used }); @@ -142,7 +143,7 @@ export default class Registration extends React.Component { } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - // eslint-disable-next-line camelcase + // eslint-disable-next-line UNSAFE_componentWillReceiveProps(newProps) { if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; @@ -180,7 +181,7 @@ export default class Registration extends React.Component { } } - const {hsUrl, isUrl} = serverConfig; + const { hsUrl, isUrl } = serverConfig; const cli = createClient({ baseUrl: hsUrl, idBaseUrl: isUrl, @@ -216,7 +217,7 @@ export default class Registration extends React.Component { if (!this.state.doingUIAuth) { await this.makeRegisterRequest(null); // This should never succeed since we specified no auth object. - console.log("Expecting 401 from register request but got success!"); + logger.log("Expecting 401 from register request but got success!"); } } catch (e) { if (e.httpStatus === 401) { @@ -230,7 +231,7 @@ export default class Registration extends React.Component { // the user off to the login page to figure their account out. if (ssoFlow) { // Redirect to login page - server probably expects SSO only - dis.dispatch({action: 'start_login'}); + dis.dispatch({ action: 'start_login' }); } else { this.setState({ serverErrorIsFatal: true, // fatal because user cannot continue on this server @@ -240,13 +241,13 @@ export default class Registration extends React.Component { }); } } else { - console.log("Unable to query for supported registration methods.", e); + logger.log("Unable to query for supported registration methods.", e); showGenericError(e); } } } - private onFormSubmit = formVals => { + private onFormSubmit = async (formVals): Promise => { this.setState({ errorText: "", busy: true, @@ -267,9 +268,9 @@ export default class Registration extends React.Component { session_id: sessionId, }), ); - } + }; - private onUIAuthFinished = async (success, response, extra) => { + private onUIAuthFinished = async (success: boolean, response: any) => { if (!success) { let msg = response.message || response.toString(); // can we give a better error message? @@ -291,8 +292,8 @@ export default class Registration extends React.Component { }, ); msg =
-

{errorTop}

-

{errorDetail}

+

{ errorTop }

+

{ errorDetail }

; } else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) { let msisdnAvailable = false; @@ -331,7 +332,7 @@ export default class Registration extends React.Component { // the user had a separate guest session they didn't actually mean to replace. const [sessionOwner, sessionIsGuest] = await Lifecycle.getStoredSessionOwner(); if (sessionOwner && !sessionIsGuest && sessionOwner !== response.userId) { - console.log( + logger.log( `Found a session for ${sessionOwner} but ${response.userId} has just registered.`, ); newState.differentLoggedInUserId = sessionOwner; @@ -367,7 +368,7 @@ export default class Registration extends React.Component { const emailPusher = pushers[i]; emailPusher.data = { brand: this.props.brand }; matrixClient.setPusher(emailPusher).then(() => { - console.log("Set email branding to " + this.props.brand); + logger.log("Set email branding to " + this.props.brand); }, (error) => { console.error("Couldn't set email branding: " + error); }); @@ -432,7 +433,7 @@ export default class Registration extends React.Component { private onLoginClickWithCheck = async ev => { ev.preventDefault(); - const sessionLoaded = await Lifecycle.loadSession({ignoreGuest: true}); + const sessionLoaded = await Lifecycle.loadSession({ ignoreGuest: true }); if (!sessionLoaded) { // ok fine, there's still no session: really go to the login page this.props.onLoginClick(); @@ -442,10 +443,6 @@ export default class Registration extends React.Component { }; private renderRegisterComponent() { - const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth'); - const Spinner = sdk.getComponent('elements.Spinner'); - const RegistrationForm = sdk.getComponent('auth.RegistrationForm'); - if (this.state.matrixClient && this.state.doingUIAuth) { return { fragmentAfterLogin={this.props.fragmentAfterLogin} />

- { _t("%(ssoButtons)s Or %(usernamePassword)s", { ssoButtons: "", usernamePassword: ""}).trim() } + { _t( + "%(ssoButtons)s Or %(usernamePassword)s", + { + ssoButtons: "", + usernamePassword: "", + }, + ).trim() }

; } @@ -510,10 +513,6 @@ export default class Registration extends React.Component { } render() { - const AuthHeader = sdk.getComponent('auth.AuthHeader'); - const AuthBody = sdk.getComponent("auth.AuthBody"); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - let errorText; const err = this.state.errorText; if (err) { @@ -529,15 +528,15 @@ export default class Registration extends React.Component { }); serverDeadSection = (
- {this.state.serverDeadError} + { this.state.serverDeadError }
); } const signIn = - {_t("Already have an account? Sign in here", {}, { + { _t("Already have an account? Sign in here", {}, { a: sub => { sub }, - })} + }) } ; // Only show the 'go back' button if you're not looking at the form @@ -553,43 +552,47 @@ export default class Registration extends React.Component { let regDoneText; if (this.state.differentLoggedInUserId) { regDoneText =
-

{_t( +

{ _t( "Your new account (%(newAccountId)s) is registered, but you're already " + "logged into a different account (%(loggedInUserId)s).", { newAccountId: this.state.registeredUsername, loggedInUserId: this.state.differentLoggedInUserId, }, - )}

-

{ - const sessionLoaded = await this.onLoginClickWithCheck(event); - if (sessionLoaded) { - dis.dispatch({action: "view_welcome_page"}); - } - }}> - {_t("Continue with previous account")} + ) }

+

{ + const sessionLoaded = await this.onLoginClickWithCheck(event); + if (sessionLoaded) { + dis.dispatch({ action: "view_welcome_page" }); + } + }} + > + { _t("Continue with previous account") }

; } else if (this.state.formVals.password) { // We're the client that started the registration - regDoneText =

{_t( + regDoneText =

{ _t( "Log in to your new account.", {}, { - a: (sub) => {sub}, + a: (sub) => { sub }, }, - )}

; + ) }; } else { // We're not the original client: the user probably got to us by clicking the // email validation link. We can't offer a 'go straight to your account' link // as we don't have the original creds. - regDoneText =

{_t( + regDoneText =

{ _t( "You can now close this window or log in to your new account.", {}, { - a: (sub) => {sub}, + a: (sub) => { sub }, }, - )}

; + ) }; } body =
-

{_t("Registration Successful")}

+

{ _t("Registration Successful") }

{ regDoneText }
; } else { diff --git a/src/components/structures/auth/SetupEncryptionBody.js b/src/components/structures/auth/SetupEncryptionBody.tsx similarity index 69% rename from src/components/structures/auth/SetupEncryptionBody.js rename to src/components/structures/auth/SetupEncryptionBody.tsx index 803df19d00..87d74a5a79 100644 --- a/src/components/structures/auth/SetupEncryptionBody.js +++ b/src/components/structures/auth/SetupEncryptionBody.tsx @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020-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. @@ -15,41 +15,45 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import Modal from '../../../Modal'; import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDialog'; -import * as sdk from '../../../index'; -import { - SetupEncryptionStore, - PHASE_LOADING, - PHASE_INTRO, - PHASE_BUSY, - PHASE_DONE, - PHASE_CONFIRM_SKIP, - PHASE_FINISHED, -} from '../../../stores/SetupEncryptionStore'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { ISecretStorageKeyInfo } from 'matrix-js-sdk/src/crypto/api'; +import EncryptionPanel from "../../views/right_panel/EncryptionPanel"; +import AccessibleButton from '../../views/elements/AccessibleButton'; +import Spinner from '../../views/elements/Spinner'; +import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; +import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; -function keyHasPassphrase(keyInfo) { - return ( +import { logger } from "matrix-js-sdk/src/logger"; + +function keyHasPassphrase(keyInfo: ISecretStorageKeyInfo): boolean { + return Boolean( keyInfo.passphrase && keyInfo.passphrase.salt && - keyInfo.passphrase.iterations + keyInfo.passphrase.iterations, ); } -@replaceableComponent("structures.auth.SetupEncryptionBody") -export default class SetupEncryptionBody extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - }; +interface IProps { + onFinished: (boolean) => void; +} - constructor() { - super(); +interface IState { + phase: Phase; + verificationRequest: VerificationRequest; + backupInfo: IKeyBackupInfo; +} + +@replaceableComponent("structures.auth.SetupEncryptionBody") +export default class SetupEncryptionBody extends React.Component { + constructor(props) { + super(props); const store = SetupEncryptionStore.sharedInstance(); - store.on("update", this._onStoreUpdate); + store.on("update", this.onStoreUpdate); store.start(); this.state = { phase: store.phase, @@ -61,10 +65,10 @@ export default class SetupEncryptionBody extends React.Component { }; } - _onStoreUpdate = () => { + private onStoreUpdate = () => { const store = SetupEncryptionStore.sharedInstance(); - if (store.phase === PHASE_FINISHED) { - this.props.onFinished(); + if (store.phase === Phase.Finished) { + this.props.onFinished(true); return; } this.setState({ @@ -74,18 +78,18 @@ export default class SetupEncryptionBody extends React.Component { }); }; - componentWillUnmount() { + public componentWillUnmount() { const store = SetupEncryptionStore.sharedInstance(); - store.off("update", this._onStoreUpdate); + store.off("update", this.onStoreUpdate); store.stop(); } - _onUsePassphraseClick = async () => { + private onUsePassphraseClick = async () => { const store = SetupEncryptionStore.sharedInstance(); store.usePassPhrase(); - } + }; - _onVerifyClick = () => { + private onVerifyClick = () => { const cli = MatrixClientPeg.get(); const userId = cli.getUserId(); const requestPromise = cli.requestVerification(userId); @@ -99,44 +103,46 @@ export default class SetupEncryptionBody extends React.Component { request.cancel(); }, }); - } + }; - onSkipClick = () => { + private onSkipClick = () => { const store = SetupEncryptionStore.sharedInstance(); store.skip(); - } + }; - onSkipConfirmClick = () => { + private onSkipConfirmClick = () => { const store = SetupEncryptionStore.sharedInstance(); store.skipConfirm(); - } + }; - onSkipBackClick = () => { + private onSkipBackClick = () => { const store = SetupEncryptionStore.sharedInstance(); store.returnAfterSkip(); - } + }; - onDoneClick = () => { + private onDoneClick = () => { const store = SetupEncryptionStore.sharedInstance(); store.done(); - } + }; - render() { - const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); + private onEncryptionPanelClose = () => { + this.props.onFinished(false); + }; + public render() { const { phase, } = this.state; if (this.state.verificationRequest) { - const EncryptionPanel = sdk.getComponent("views.right_panel.EncryptionPanel"); return ; - } else if (phase === PHASE_INTRO) { + } else if (phase === Phase.Intro) { const store = SetupEncryptionStore.sharedInstance(); let recoveryKeyPrompt; if (store.keyInfo && keyHasPassphrase(store.keyInfo)) { @@ -147,88 +153,87 @@ export default class SetupEncryptionBody extends React.Component { let useRecoveryKeyButton; if (recoveryKeyPrompt) { - useRecoveryKeyButton = - {recoveryKeyPrompt} + useRecoveryKeyButton = + { recoveryKeyPrompt } ; } let verifyButton; if (store.hasDevicesToVerifyAgainst) { - verifyButton = + verifyButton = { _t("Use another login") } ; } return (
-

{_t( +

{ _t( "Verify your identity to access encrypted messages and prove your identity to others.", - )}

+ ) }

- {verifyButton} - {useRecoveryKeyButton} + { verifyButton } + { useRecoveryKeyButton } - {_t("Skip")} + { _t("Skip") }
); - } else if (phase === PHASE_DONE) { + } else if (phase === Phase.Done) { let message; if (this.state.backupInfo) { - message =

{_t( + message =

{ _t( "Your new session is now verified. It has access to your " + "encrypted messages, and other users will see it as trusted.", - )}

; + ) }

; } else { - message =

{_t( + message =

{ _t( "Your new session is now verified. Other users will see it as trusted.", - )}

; + ) }

; } return (
- {message} + { message }
- {_t("Done")} + { _t("Done") }
); - } else if (phase === PHASE_CONFIRM_SKIP) { + } else if (phase === Phase.ConfirmSkip) { return (
-

{_t( +

{ _t( "Without verifying, you won’t have access to all your messages " + "and may appear as untrusted to others.", - )}

+ ) }

- {_t("Skip")} + { _t("Skip") } - {_t("Go Back")} + { _t("Go Back") }
); - } else if (phase === PHASE_BUSY || phase === PHASE_LOADING) { - const Spinner = sdk.getComponent('views.elements.Spinner'); + } else if (phase === Phase.Busy || phase === Phase.Loading) { return ; } else { - console.log(`SetupEncryptionBody: Unknown phase ${phase}`); + logger.log(`SetupEncryptionBody: Unknown phase ${phase}`); } } } diff --git a/src/components/structures/auth/SoftLogout.tsx b/src/components/structures/auth/SoftLogout.tsx index fa9207efdd..a943f47e66 100644 --- a/src/components/structures/auth/SoftLogout.tsx +++ b/src/components/structures/auth/SoftLogout.tsx @@ -15,17 +15,24 @@ limitations under the License. */ import React from 'react'; -import {_t} from '../../../languageHandler'; -import * as sdk from '../../../index'; +import { _t } from '../../../languageHandler'; import dis from '../../../dispatcher/dispatcher'; import * as Lifecycle from '../../../Lifecycle'; import Modal from '../../../Modal'; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import {ISSOFlow, LoginFlow, sendLoginRequest} from "../../../Login"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { ISSOFlow, LoginFlow, sendLoginRequest } from "../../../Login"; import AuthPage from "../../views/auth/AuthPage"; -import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "../../../BasePlatform"; +import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY } from "../../../BasePlatform"; import SSOButtons from "../../views/elements/SSOButtons"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import ConfirmWipeDeviceDialog from '../../views/dialogs/ConfirmWipeDeviceDialog'; +import Field from '../../views/elements/Field'; +import AccessibleButton from '../../views/elements/AccessibleButton'; +import Spinner from "../../views/elements/Spinner"; +import AuthHeader from "../../views/auth/AuthHeader"; +import AuthBody from "../../views/auth/AuthBody"; + +import { logger } from "matrix-js-sdk/src/logger"; const LOGIN_VIEW = { LOADING: 1, @@ -49,7 +56,7 @@ interface IProps { fragmentAfterLogin?: string; // Called when the SSO login completes - onTokenLoginCompleted: () => void, + onTokenLoginCompleted: () => void; } interface IState { @@ -79,7 +86,7 @@ export default class SoftLogout extends React.Component { componentDidMount(): void { // We've ended up here when we don't need to - navigate to login if (!Lifecycle.isSoftLogout()) { - dis.dispatch({action: "start_login"}); + dis.dispatch({ action: "start_login" }); return; } @@ -94,12 +101,11 @@ export default class SoftLogout extends React.Component { } onClearAll = () => { - const ConfirmWipeDeviceDialog = sdk.getComponent('dialogs.ConfirmWipeDeviceDialog'); Modal.createTrackedDialog('Clear Data', 'Soft Logout', ConfirmWipeDeviceDialog, { onFinished: (wipeData) => { if (!wipeData) return; - console.log("Clearing data from soft-logged-out session"); + logger.log("Clearing data from soft-logged-out session"); Lifecycle.logout(); }, }); @@ -109,7 +115,7 @@ export default class SoftLogout extends React.Component { const queryParams = this.props.realQueryParams; const hasAllParams = queryParams && queryParams['loginToken']; if (hasAllParams) { - this.setState({loginView: LOGIN_VIEW.LOADING}); + this.setState({ loginView: LOGIN_VIEW.LOADING }); this.trySsoLogin(); return; } @@ -125,18 +131,18 @@ export default class SoftLogout extends React.Component { } onPasswordChange = (ev) => { - this.setState({password: ev.target.value}); + this.setState({ password: ev.target.value }); }; onForgotPassword = () => { - dis.dispatch({action: 'start_password_recovery'}); + dis.dispatch({ action: 'start_password_recovery' }); }; onPasswordLogin = async (ev) => { ev.preventDefault(); ev.stopPropagation(); - this.setState({busy: true}); + this.setState({ busy: true }); const hsUrl = MatrixClientPeg.get().getHomeserverUrl(); const isUrl = MatrixClientPeg.get().getIdentityServerUrl(); @@ -168,12 +174,12 @@ export default class SoftLogout extends React.Component { Lifecycle.hydrateSession(credentials).catch((e) => { console.error(e); - this.setState({busy: false, errorText: _t("Failed to re-authenticate")}); + this.setState({ busy: false, errorText: _t("Failed to re-authenticate") }); }); }; async trySsoLogin() { - this.setState({busy: true}); + this.setState({ busy: true }); const hsUrl = localStorage.getItem(SSO_HOMESERVER_URL_KEY); const isUrl = localStorage.getItem(SSO_ID_SERVER_URL_KEY) || MatrixClientPeg.get().getIdentityServerUrl(); @@ -188,7 +194,7 @@ export default class SoftLogout extends React.Component { credentials = await sendLoginRequest(hsUrl, isUrl, loginType, loginParams); } catch (e) { console.error(e); - this.setState({busy: false, loginView: LOGIN_VIEW.UNSUPPORTED}); + this.setState({ busy: false, loginView: LOGIN_VIEW.UNSUPPORTED }); return; } @@ -196,13 +202,12 @@ export default class SoftLogout extends React.Component { if (this.props.onTokenLoginCompleted) this.props.onTokenLoginCompleted(); }).catch((e) => { console.error(e); - this.setState({busy: false, loginView: LOGIN_VIEW.UNSUPPORTED}); + this.setState({ busy: false, loginView: LOGIN_VIEW.UNSUPPORTED }); }); } private renderSignInSection() { if (this.state.loginView === LOGIN_VIEW.LOADING) { - const Spinner = sdk.getComponent("elements.Spinner"); return ; } @@ -214,12 +219,9 @@ export default class SoftLogout extends React.Component { } if (this.state.loginView === LOGIN_VIEW.PASSWORD) { - const Field = sdk.getComponent("elements.Field"); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - let error = null; if (this.state.errorText) { - error = {this.state.errorText}; + error = { this.state.errorText }; } if (!introText) { @@ -228,8 +230,8 @@ export default class SoftLogout extends React.Component { return (
-

{introText}

- {error} +

{ introText }

+ { error } { type="submit" disabled={this.state.busy} > - {_t("Sign In")} + { _t("Sign In") } - {_t("Forgotten your password?")} + { _t("Forgotten your password?") } ); @@ -262,7 +264,7 @@ export default class SoftLogout extends React.Component { return (
-

{introText}

+

{ introText }

{ // Default: assume unsupported/error return (

- {_t( + { _t( "You cannot sign in to your account. Please contact your " + "homeserver admin for more information.", - )} + ) }

); } render() { - const AuthHeader = sdk.getComponent("auth.AuthHeader"); - const AuthBody = sdk.getComponent("auth.AuthBody"); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - return (

- {_t("You're signed out")} + { _t("You're signed out") }

-

{_t("Sign in")}

+

{ _t("Sign in") }

- {this.renderSignInSection()} + { this.renderSignInSection() }
-

{_t("Clear personal data")}

+

{ _t("Clear personal data") }

- {_t( + { _t( "Warning: Your personal data (including encryption keys) is still stored " + "in this session. Clear it if you're finished using this session, or want to sign " + "in to another account.", - )} + ) }

- {_t("Clear all data")} + { _t("Clear all data") }
diff --git a/src/components/views/audio_messages/AudioPlayer.tsx b/src/components/views/audio_messages/AudioPlayer.tsx new file mode 100644 index 0000000000..b83f89fe5b --- /dev/null +++ b/src/components/views/audio_messages/AudioPlayer.tsx @@ -0,0 +1,93 @@ +/* +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, { createRef, ReactNode, RefObject } from "react"; +import PlayPauseButton from "./PlayPauseButton"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { formatBytes } from "../../../utils/FormattingUtils"; +import DurationClock from "./DurationClock"; +import { Key } from "../../../Keyboard"; +import { _t } from "../../../languageHandler"; +import SeekBar from "./SeekBar"; +import PlaybackClock from "./PlaybackClock"; +import AudioPlayerBase from "./AudioPlayerBase"; + +@replaceableComponent("views.audio_messages.AudioPlayer") +export default class AudioPlayer extends AudioPlayerBase { + private playPauseRef: RefObject = createRef(); + private seekRef: RefObject = createRef(); + + private onKeyDown = (ev: React.KeyboardEvent) => { + // stopPropagation() prevents the FocusComposer catch-all from triggering, + // but we need to do it on key down instead of press (even though the user + // interaction is typically on press). + if (ev.key === Key.SPACE) { + ev.stopPropagation(); + this.playPauseRef.current?.toggleState(); + } else if (ev.key === Key.ARROW_LEFT) { + ev.stopPropagation(); + this.seekRef.current?.left(); + } else if (ev.key === Key.ARROW_RIGHT) { + ev.stopPropagation(); + this.seekRef.current?.right(); + } + }; + + protected renderFileSize(): string { + const bytes = this.props.playback.sizeBytes; + if (!bytes) return null; + + // Not translated here - we're just presenting the data which should already + // be translated if needed. + return `(${formatBytes(bytes)})`; + } + + protected renderComponent(): ReactNode { + // tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard + // events for accessibility + return ( +
+
+ +
+ + { this.props.mediaName || _t("Unnamed audio") } + +
+ +   { /* easiest way to introduce a gap between the components */ } + { this.renderFileSize() } +
+
+
+
+ + +
+
+ ); + } +} diff --git a/src/components/views/voice_messages/RecordingPlayback.tsx b/src/components/views/audio_messages/AudioPlayerBase.tsx similarity index 57% rename from src/components/views/voice_messages/RecordingPlayback.tsx rename to src/components/views/audio_messages/AudioPlayerBase.tsx index 776997cec2..d8fc9d507f 100644 --- a/src/components/views/voice_messages/RecordingPlayback.tsx +++ b/src/components/views/audio_messages/AudioPlayerBase.tsx @@ -14,24 +14,29 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {Playback, PlaybackState} from "../../../voice/Playback"; -import React, {ReactNode} from "react"; -import {UPDATE_EVENT} from "../../../stores/AsyncStore"; -import PlaybackWaveform from "./PlaybackWaveform"; -import PlayPauseButton from "./PlayPauseButton"; -import PlaybackClock from "./PlaybackClock"; +import { Playback, PlaybackState } from "../../../audio/Playback"; +import { TileShape } from "../rooms/EventTile"; +import React, { ReactNode } from "react"; +import { UPDATE_EVENT } from "../../../stores/AsyncStore"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { _t } from "../../../languageHandler"; interface IProps { // Playback instance to render. Cannot change during component lifecycle: create // an all-new component instead. playback: Playback; + + mediaName?: string; + tileShape?: TileShape; } interface IState { playbackPhase: PlaybackState; + error?: boolean; } -export default class RecordingPlayback extends React.PureComponent { +@replaceableComponent("views.audio_messages.AudioPlayerBase") +export default abstract class AudioPlayerBase extends React.PureComponent { constructor(props: IProps) { super(props); @@ -44,19 +49,22 @@ export default class RecordingPlayback extends React.PureComponent { + console.error("Error processing audio file:", e); + this.setState({ error: true }); + }); } private onPlaybackUpdate = (ev: PlaybackState) => { - this.setState({playbackPhase: ev}); + this.setState({ playbackPhase: ev }); }; + protected abstract renderComponent(): ReactNode; + public render(): ReactNode { - return
- - - -
+ return <> + { this.renderComponent() } + { this.state.error &&
{ _t("Error downloading audio") }
} + ; } } diff --git a/src/components/views/voice_messages/Clock.tsx b/src/components/views/audio_messages/Clock.tsx similarity index 62% rename from src/components/views/voice_messages/Clock.tsx rename to src/components/views/audio_messages/Clock.tsx index 23e6762c52..69244cc5ad 100644 --- a/src/components/views/voice_messages/Clock.tsx +++ b/src/components/views/audio_messages/Clock.tsx @@ -15,34 +15,30 @@ limitations under the License. */ import React from "react"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { formatSeconds } from "../../../DateUtils"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; -interface IProps { +export interface IProps { seconds: number; } -interface IState { -} - /** * Simply converts seconds into minutes and seconds. Note that hours will not be * displayed, making it possible to see "82:29". */ -@replaceableComponent("views.voice_messages.Clock") -export default class Clock extends React.Component { +@replaceableComponent("views.audio_messages.Clock") +export default class Clock extends React.Component { public constructor(props) { super(props); } - shouldComponentUpdate(nextProps: Readonly, nextState: Readonly, nextContext: any): boolean { + shouldComponentUpdate(nextProps: Readonly): boolean { const currentFloor = Math.floor(this.props.seconds); const nextFloor = Math.floor(nextProps.seconds); return currentFloor !== nextFloor; } public render() { - const minutes = Math.floor(this.props.seconds / 60).toFixed(0).padStart(2, '0'); - const seconds = Math.floor(this.props.seconds % 60).toFixed(0).padStart(2, '0'); // hide millis - return {minutes}:{seconds}; + return { formatSeconds(this.props.seconds) }; } } diff --git a/src/components/views/audio_messages/DurationClock.tsx b/src/components/views/audio_messages/DurationClock.tsx new file mode 100644 index 0000000000..15bc6c98a4 --- /dev/null +++ b/src/components/views/audio_messages/DurationClock.tsx @@ -0,0 +1,55 @@ +/* +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 from "react"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Clock from "./Clock"; +import { Playback } from "../../../audio/Playback"; + +interface IProps { + playback: Playback; +} + +interface IState { + durationSeconds: number; +} + +/** + * A clock which shows a clip's maximum duration. + */ +@replaceableComponent("views.audio_messages.DurationClock") +export default class DurationClock extends React.PureComponent { + public constructor(props) { + super(props); + + this.state = { + // we track the duration on state because we won't really know what the clip duration + // is until the first time update, and as a PureComponent we are trying to dedupe state + // updates as much as possible. This is just the easiest way to avoid a forceUpdate() or + // member property to track "did we get a duration". + durationSeconds: this.props.playback.clockInfo.durationSeconds, + }; + this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate); + } + + private onTimeUpdate = (time: number[]) => { + this.setState({ durationSeconds: time[1] }); + }; + + public render() { + return ; + } +} diff --git a/src/components/views/voice_messages/LiveRecordingClock.tsx b/src/components/views/audio_messages/LiveRecordingClock.tsx similarity index 52% rename from src/components/views/voice_messages/LiveRecordingClock.tsx rename to src/components/views/audio_messages/LiveRecordingClock.tsx index b82539eb16..e7330efc1d 100644 --- a/src/components/views/voice_messages/LiveRecordingClock.tsx +++ b/src/components/views/audio_messages/LiveRecordingClock.tsx @@ -15,9 +15,10 @@ limitations under the License. */ import React from "react"; -import {IRecordingUpdate, VoiceRecording} from "../../../voice/VoiceRecording"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { IRecordingUpdate, VoiceRecording } from "../../../audio/VoiceRecording"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; import Clock from "./Clock"; +import { MarkedExecution } from "../../../utils/MarkedExecution"; interface IProps { recorder: VoiceRecording; @@ -30,18 +31,33 @@ interface IState { /** * A clock for a live recording. */ -@replaceableComponent("views.voice_messages.LiveRecordingClock") +@replaceableComponent("views.audio_messages.LiveRecordingClock") export default class LiveRecordingClock extends React.PureComponent { - public constructor(props) { - super(props); + private seconds = 0; + private scheduledUpdate = new MarkedExecution( + () => this.updateClock(), + () => requestAnimationFrame(() => this.scheduledUpdate.trigger()), + ); - this.state = {seconds: 0}; - this.props.recorder.liveData.onUpdate(this.onRecordingUpdate); + constructor(props) { + super(props); + this.state = { + seconds: 0, + }; } - private onRecordingUpdate = (update: IRecordingUpdate) => { - this.setState({seconds: update.timeSeconds}); - }; + componentDidMount() { + this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => { + this.seconds = update.timeSeconds; + this.scheduledUpdate.mark(); + }); + } + + private updateClock() { + this.setState({ + seconds: this.seconds, + }); + } public render() { return ; diff --git a/src/components/views/audio_messages/LiveRecordingWaveform.tsx b/src/components/views/audio_messages/LiveRecordingWaveform.tsx new file mode 100644 index 0000000000..73e18626fe --- /dev/null +++ b/src/components/views/audio_messages/LiveRecordingWaveform.tsx @@ -0,0 +1,69 @@ +/* +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 from "react"; +import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { arrayFastResample, arraySeed } from "../../../utils/arrays"; +import Waveform from "./Waveform"; +import { MarkedExecution } from "../../../utils/MarkedExecution"; + +interface IProps { + recorder: VoiceRecording; +} + +interface IState { + waveform: number[]; +} + +/** + * A waveform which shows the waveform of a live recording + */ +@replaceableComponent("views.audio_messages.LiveRecordingWaveform") +export default class LiveRecordingWaveform extends React.PureComponent { + public static defaultProps = { + progress: 1, + }; + + private waveform: number[] = []; + private scheduledUpdate = new MarkedExecution( + () => this.updateWaveform(), + () => requestAnimationFrame(() => this.scheduledUpdate.trigger()), + ); + + constructor(props) { + super(props); + this.state = { + waveform: arraySeed(0, RECORDING_PLAYBACK_SAMPLES), + }; + } + + componentDidMount() { + this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => { + // The incoming data is between zero and one, so we don't need to clamp/rescale it. + this.waveform = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES); + this.scheduledUpdate.mark(); + }); + } + + private updateWaveform() { + this.setState({ waveform: this.waveform }); + } + + public render() { + return ; + } +} diff --git a/src/components/views/voice_messages/PlayPauseButton.tsx b/src/components/views/audio_messages/PlayPauseButton.tsx similarity index 67% rename from src/components/views/voice_messages/PlayPauseButton.tsx rename to src/components/views/audio_messages/PlayPauseButton.tsx index 1f87eb012d..de2822cc39 100644 --- a/src/components/views/voice_messages/PlayPauseButton.tsx +++ b/src/components/views/audio_messages/PlayPauseButton.tsx @@ -14,14 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {ReactNode} from "react"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import React, { ReactNode } from "react"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; -import {_t} from "../../../languageHandler"; -import {Playback, PlaybackState} from "../../../voice/Playback"; +import { _t } from "../../../languageHandler"; +import { Playback, PlaybackState } from "../../../audio/Playback"; import classNames from "classnames"; -interface IProps { +// omitted props are handled by render function +interface IProps extends Omit, "title" | "onClick" | "disabled"> { // Playback instance to manipulate. Cannot change during the component lifecycle. playback: Playback; @@ -33,19 +34,25 @@ interface IProps { * Displays a play/pause button (activating the play/pause function of the recorder) * to be displayed in reference to a recording. */ -@replaceableComponent("views.voice_messages.PlayPauseButton") +@replaceableComponent("views.audio_messages.PlayPauseButton") export default class PlayPauseButton extends React.PureComponent { public constructor(props) { super(props); } - private onClick = async () => { - await this.props.playback.toggle(); + private onClick = () => { + // noinspection JSIgnoredPromiseFromCall + this.toggleState(); }; + public async toggleState() { + await this.props.playback.toggle(); + } + public render(): ReactNode { - const isPlaying = this.props.playback.isPlaying; - const isDisabled = this.props.playbackPhase === PlaybackState.Decoding; + const { playback, playbackPhase, ...restProps } = this.props; + const isPlaying = playback.isPlaying; + const isDisabled = playbackPhase === PlaybackState.Decoding; const classes = classNames('mx_PlayPauseButton', { 'mx_PlayPauseButton_play': !isPlaying, 'mx_PlayPauseButton_pause': isPlaying, @@ -56,6 +63,7 @@ export default class PlayPauseButton extends React.PureComponent { title={isPlaying ? _t("Pause") : _t("Play")} onClick={this.onClick} disabled={isDisabled} + {...restProps} />; } } diff --git a/src/components/views/voice_messages/PlaybackClock.tsx b/src/components/views/audio_messages/PlaybackClock.tsx similarity index 73% rename from src/components/views/voice_messages/PlaybackClock.tsx rename to src/components/views/audio_messages/PlaybackClock.tsx index 2e8ec9a3e7..affb025d86 100644 --- a/src/components/views/voice_messages/PlaybackClock.tsx +++ b/src/components/views/audio_messages/PlaybackClock.tsx @@ -15,13 +15,18 @@ limitations under the License. */ import React from "react"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; import Clock from "./Clock"; -import {Playback, PlaybackState} from "../../../voice/Playback"; -import {UPDATE_EVENT} from "../../../stores/AsyncStore"; +import { Playback, PlaybackState } from "../../../audio/Playback"; +import { UPDATE_EVENT } from "../../../stores/AsyncStore"; interface IProps { playback: Playback; + + // The default number of seconds to show when the playback has completed or + // has not started. Not used during playback, even when paused. Defaults to + // clip duration length. + defaultDisplaySeconds?: number; } interface IState { @@ -33,7 +38,7 @@ interface IState { /** * A clock for a playback of a recording. */ -@replaceableComponent("views.voice_messages.PlaybackClock") +@replaceableComponent("views.audio_messages.PlaybackClock") export default class PlaybackClock extends React.PureComponent { public constructor(props) { super(props); @@ -54,17 +59,21 @@ export default class PlaybackClock extends React.PureComponent { private onPlaybackUpdate = (ev: PlaybackState) => { // Convert Decoding -> Stopped because we don't care about the distinction here if (ev === PlaybackState.Decoding) ev = PlaybackState.Stopped; - this.setState({playbackPhase: ev}); + this.setState({ playbackPhase: ev }); }; private onTimeUpdate = (time: number[]) => { - this.setState({seconds: time[0], durationSeconds: time[1]}); + this.setState({ seconds: time[0], durationSeconds: time[1] }); }; public render() { let seconds = this.state.seconds; if (this.state.playbackPhase === PlaybackState.Stopped) { - seconds = this.state.durationSeconds; + if (Number.isFinite(this.props.defaultDisplaySeconds)) { + seconds = this.props.defaultDisplaySeconds; + } else { + seconds = this.state.durationSeconds; + } } return ; } diff --git a/src/components/views/voice_messages/PlaybackWaveform.tsx b/src/components/views/audio_messages/PlaybackWaveform.tsx similarity index 81% rename from src/components/views/voice_messages/PlaybackWaveform.tsx rename to src/components/views/audio_messages/PlaybackWaveform.tsx index 2e9f163f5e..96fd3f5ae2 100644 --- a/src/components/views/voice_messages/PlaybackWaveform.tsx +++ b/src/components/views/audio_messages/PlaybackWaveform.tsx @@ -15,11 +15,11 @@ limitations under the License. */ import React from "react"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {arraySeed, arrayTrimFill} from "../../../utils/arrays"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { arraySeed, arrayTrimFill } from "../../../utils/arrays"; import Waveform from "./Waveform"; -import {Playback, PLAYBACK_WAVEFORM_SAMPLES} from "../../../voice/Playback"; -import {percentageOf} from "../../../utils/numbers"; +import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../audio/Playback"; +import { percentageOf } from "../../../utils/numbers"; interface IProps { playback: Playback; @@ -33,7 +33,7 @@ interface IState { /** * A waveform which shows the waveform of a previously recorded recording */ -@replaceableComponent("views.voice_messages.PlaybackWaveform") +@replaceableComponent("views.audio_messages.PlaybackWaveform") export default class PlaybackWaveform extends React.PureComponent { public constructor(props) { super(props); @@ -53,13 +53,13 @@ export default class PlaybackWaveform extends React.PureComponent { - this.setState({heights: this.toHeights(waveform)}); + this.setState({ heights: this.toHeights(waveform) }); }; private onTimeUpdate = (time: number[]) => { // Track percentages to a general precision to avoid over-waking the component. const progress = Number(percentageOf(time[0], 0, time[1]).toFixed(3)); - this.setState({progress}); + this.setState({ progress }); }; public render() { diff --git a/src/components/views/audio_messages/RecordingPlayback.tsx b/src/components/views/audio_messages/RecordingPlayback.tsx new file mode 100644 index 0000000000..e3f612c9b6 --- /dev/null +++ b/src/components/views/audio_messages/RecordingPlayback.tsx @@ -0,0 +1,43 @@ +/* +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, { ReactNode } from "react"; +import PlayPauseButton from "./PlayPauseButton"; +import PlaybackClock from "./PlaybackClock"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { TileShape } from "../rooms/EventTile"; +import PlaybackWaveform from "./PlaybackWaveform"; +import AudioPlayerBase from "./AudioPlayerBase"; + +@replaceableComponent("views.audio_messages.RecordingPlayback") +export default class RecordingPlayback extends AudioPlayerBase { + private get isWaveformable(): boolean { + return this.props.tileShape !== TileShape.Notif + && this.props.tileShape !== TileShape.FileGrid + && this.props.tileShape !== TileShape.Pinned; + } + + protected renderComponent(): ReactNode { + const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : ''; + return ( +
+ + + { this.isWaveformable && } +
+ ); + } +} diff --git a/src/components/views/audio_messages/SeekBar.tsx b/src/components/views/audio_messages/SeekBar.tsx new file mode 100644 index 0000000000..f0c03bb032 --- /dev/null +++ b/src/components/views/audio_messages/SeekBar.tsx @@ -0,0 +1,112 @@ +/* +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 { Playback, PlaybackState } from "../../../audio/Playback"; +import React, { ChangeEvent, CSSProperties, ReactNode } from "react"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { MarkedExecution } from "../../../utils/MarkedExecution"; +import { percentageOf } from "../../../utils/numbers"; + +interface IProps { + // Playback instance to render. Cannot change during component lifecycle: create + // an all-new component instead. + playback: Playback; + + // Tab index for the underlying component. Useful if the seek bar is in a managed state. + // Defaults to zero. + tabIndex?: number; + + playbackPhase: PlaybackState; +} + +interface IState { + percentage: number; +} + +interface ISeekCSS extends CSSProperties { + '--fillTo': number; +} + +const ARROW_SKIP_SECONDS = 5; // arbitrary + +@replaceableComponent("views.audio_messages.SeekBar") +export default class SeekBar extends React.PureComponent { + // We use an animation frame request to avoid overly spamming prop updates, even if we aren't + // really using anything demanding on the CSS front. + + private animationFrameFn = new MarkedExecution( + () => this.doUpdate(), + () => requestAnimationFrame(() => this.animationFrameFn.trigger())); + + public static defaultProps = { + tabIndex: 0, + }; + + constructor(props: IProps) { + super(props); + + this.state = { + percentage: 0, + }; + + // We don't need to de-register: the class handles this for us internally + this.props.playback.clockInfo.liveData.onUpdate(() => this.animationFrameFn.mark()); + } + + private doUpdate() { + this.setState({ + percentage: percentageOf( + this.props.playback.clockInfo.timeSeconds, + 0, + this.props.playback.clockInfo.durationSeconds), + }); + } + + public left() { + // noinspection JSIgnoredPromiseFromCall + this.props.playback.skipTo(this.props.playback.clockInfo.timeSeconds - ARROW_SKIP_SECONDS); + } + + public right() { + // noinspection JSIgnoredPromiseFromCall + this.props.playback.skipTo(this.props.playback.clockInfo.timeSeconds + ARROW_SKIP_SECONDS); + } + + private onChange = (ev: ChangeEvent) => { + // Thankfully, onChange is only called when the user changes the value, not when we + // change the value on the component. We can use this as a reliable "skip to X" function. + // + // noinspection JSIgnoredPromiseFromCall + this.props.playback.skipTo(Number(ev.target.value) * this.props.playback.clockInfo.durationSeconds); + }; + + public render(): ReactNode { + // We use a range input to avoid having to re-invent accessibility handling on + // a custom set of divs. + return ; + } +} diff --git a/src/components/views/voice_messages/Waveform.tsx b/src/components/views/audio_messages/Waveform.tsx similarity index 75% rename from src/components/views/voice_messages/Waveform.tsx rename to src/components/views/audio_messages/Waveform.tsx index 840a5a12b3..4e44abdf46 100644 --- a/src/components/views/voice_messages/Waveform.tsx +++ b/src/components/views/audio_messages/Waveform.tsx @@ -15,8 +15,13 @@ limitations under the License. */ import React from "react"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; import classNames from "classnames"; +import { CSSProperties } from "react"; + +interface WaveformCSSProperties extends CSSProperties { + '--barHeight': number; +} interface IProps { relHeights: number[]; // relative heights (0-1) @@ -34,27 +39,29 @@ interface IState { * For CSS purposes, a mx_Waveform_bar_100pct class is added when the bar should be * "filled", as a demonstration of the progress property. */ -@replaceableComponent("views.voice_messages.Waveform") +@replaceableComponent("views.audio_messages.Waveform") export default class Waveform extends React.PureComponent { public static defaultProps = { progress: 1, }; - public constructor(props) { - super(props); - } - public render() { return
- {this.props.relHeights.map((h, i) => { + { this.props.relHeights.map((h, i) => { const progress = this.props.progress; const isCompleteBar = (i / this.props.relHeights.length) <= progress && progress > 0; const classes = classNames({ 'mx_Waveform_bar': true, 'mx_Waveform_bar_100pct': isCompleteBar, }); - return ; - })} + return ; + }) }
; } } diff --git a/src/components/views/auth/AuthBody.js b/src/components/views/auth/AuthBody.tsx similarity index 87% rename from src/components/views/auth/AuthBody.js rename to src/components/views/auth/AuthBody.tsx index 2cb72b5e1d..3543a573d7 100644 --- a/src/components/views/auth/AuthBody.js +++ b/src/components/views/auth/AuthBody.tsx @@ -15,11 +15,11 @@ limitations under the License. */ import React from 'react'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.auth.AuthBody") export default class AuthBody extends React.PureComponent { - render() { + public render(): React.ReactNode { return
{ this.props.children }
; diff --git a/src/components/views/auth/AuthFooter.js b/src/components/views/auth/AuthFooter.tsx similarity index 90% rename from src/components/views/auth/AuthFooter.js rename to src/components/views/auth/AuthFooter.tsx index f167e16283..00bced8c39 100644 --- a/src/components/views/auth/AuthFooter.js +++ b/src/components/views/auth/AuthFooter.tsx @@ -18,11 +18,11 @@ limitations under the License. import { _t } from '../../../languageHandler'; import React from 'react'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.auth.AuthFooter") export default class AuthFooter extends React.Component { - render() { + public render(): React.ReactNode { return (
{ _t("powered by Matrix") } diff --git a/src/components/views/auth/AuthHeader.js b/src/components/views/auth/AuthHeader.tsx similarity index 66% rename from src/components/views/auth/AuthHeader.js rename to src/components/views/auth/AuthHeader.tsx index 323299b3a8..cab7da1468 100644 --- a/src/components/views/auth/AuthHeader.js +++ b/src/components/views/auth/AuthHeader.tsx @@ -16,20 +16,17 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import AuthHeaderLogo from "./AuthHeaderLogo"; +import LanguageSelector from "./LanguageSelector"; + +interface IProps { + disableLanguageSelector?: boolean; +} @replaceableComponent("views.auth.AuthHeader") -export default class AuthHeader extends React.Component { - static propTypes = { - disableLanguageSelector: PropTypes.bool, - }; - - render() { - const AuthHeaderLogo = sdk.getComponent('auth.AuthHeaderLogo'); - const LanguageSelector = sdk.getComponent('views.auth.LanguageSelector'); - +export default class AuthHeader extends React.Component { + public render(): React.ReactNode { return (
diff --git a/src/components/views/auth/AuthHeaderLogo.js b/src/components/views/auth/AuthHeaderLogo.tsx similarity index 87% rename from src/components/views/auth/AuthHeaderLogo.js rename to src/components/views/auth/AuthHeaderLogo.tsx index b4e04799bb..b6724793a5 100644 --- a/src/components/views/auth/AuthHeaderLogo.js +++ b/src/components/views/auth/AuthHeaderLogo.tsx @@ -15,11 +15,11 @@ limitations under the License. */ import React from 'react'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.auth.AuthHeaderLogo") export default class AuthHeaderLogo extends React.PureComponent { - render() { + public render(): React.ReactNode { return
Matrix
; diff --git a/src/components/views/auth/AuthPage.js b/src/components/views/auth/AuthPage.tsx similarity index 80% rename from src/components/views/auth/AuthPage.js rename to src/components/views/auth/AuthPage.tsx index 82f7270121..c402d5b699 100644 --- a/src/components/views/auth/AuthPage.js +++ b/src/components/views/auth/AuthPage.tsx @@ -17,18 +17,16 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../index'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import AuthFooter from "./AuthFooter"; @replaceableComponent("views.auth.AuthPage") export default class AuthPage extends React.PureComponent { - render() { - const AuthFooter = sdk.getComponent('auth.AuthFooter'); - + public render(): React.ReactNode { return (
- {this.props.children} + { this.props.children }
diff --git a/src/components/views/auth/CaptchaForm.js b/src/components/views/auth/CaptchaForm.tsx similarity index 61% rename from src/components/views/auth/CaptchaForm.js rename to src/components/views/auth/CaptchaForm.tsx index 50de24d403..db0e07e046 100644 --- a/src/components/views/auth/CaptchaForm.js +++ b/src/components/views/auth/CaptchaForm.tsx @@ -14,67 +14,77 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from 'react'; -import PropTypes from 'prop-types'; +import React, { createRef } from 'react'; import { _t } from '../../../languageHandler'; import CountlyAnalytics from "../../../CountlyAnalytics"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +import { logger } from "matrix-js-sdk/src/logger"; const DIV_ID = 'mx_recaptcha'; +interface ICaptchaFormProps { + sitePublicKey: string; + onCaptchaResponse: (response: string) => void; +} + +interface ICaptchaFormState { + errorText?: string; + +} + /** * A pure UI component which displays a captcha form. */ @replaceableComponent("views.auth.CaptchaForm") -export default class CaptchaForm extends React.Component { - static propTypes = { - sitePublicKey: PropTypes.string, - - // called with the captcha response - onCaptchaResponse: PropTypes.func, - }; - +export default class CaptchaForm extends React.Component { static defaultProps = { onCaptchaResponse: () => {}, }; - constructor(props) { + private captchaWidgetId?: string; + private recaptchaContainer = createRef(); + + constructor(props: ICaptchaFormProps) { super(props); this.state = { - errorText: null, + errorText: undefined, }; - this._captchaWidgetId = null; - - this._recaptchaContainer = createRef(); - CountlyAnalytics.instance.track("onboarding_grecaptcha_begin"); } componentDidMount() { // Just putting a script tag into the returned jsx doesn't work, annoyingly, // so we do this instead. - if (global.grecaptcha) { + if (this.isRecaptchaReady()) { // already loaded - this._onCaptchaLoaded(); + this.onCaptchaLoaded(); } else { - console.log("Loading recaptcha script..."); - window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded();}; + logger.log("Loading recaptcha script..."); + window.mxOnRecaptchaLoaded = () => { this.onCaptchaLoaded(); }; const scriptTag = document.createElement('script'); scriptTag.setAttribute( - 'src', `https://www.recaptcha.net/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit`, + 'src', `https://www.recaptcha.net/recaptcha/api.js?onload=mxOnRecaptchaLoaded&render=explicit`, ); - this._recaptchaContainer.current.appendChild(scriptTag); + this.recaptchaContainer.current.appendChild(scriptTag); } } componentWillUnmount() { - this._resetRecaptcha(); + this.resetRecaptcha(); } - _renderRecaptcha(divId) { - if (!global.grecaptcha) { + // Borrowed directly from: https://github.com/codeep/react-recaptcha-google/commit/e118fa5670fa268426969323b2e7fe77698376ba + private isRecaptchaReady(): boolean { + return typeof window !== "undefined" && + typeof global.grecaptcha !== "undefined" && + typeof global.grecaptcha.render === 'function'; + } + + private renderRecaptcha(divId: string) { + if (!this.isRecaptchaReady()) { console.error("grecaptcha not loaded!"); throw new Error("Recaptcha did not load successfully"); } @@ -84,26 +94,26 @@ export default class CaptchaForm extends React.Component { console.error("No public key for recaptcha!"); throw new Error( "This server has not supplied enough information for Recaptcha " - + "authentication"); + + "authentication"); } console.info("Rendering to %s", divId); - this._captchaWidgetId = global.grecaptcha.render(divId, { + this.captchaWidgetId = global.grecaptcha.render(divId, { sitekey: publicKey, callback: this.props.onCaptchaResponse, }); } - _resetRecaptcha() { - if (this._captchaWidgetId !== null) { - global.grecaptcha.reset(this._captchaWidgetId); + private resetRecaptcha() { + if (this.captchaWidgetId) { + global?.grecaptcha?.reset(this.captchaWidgetId); } } - _onCaptchaLoaded() { - console.log("Loaded recaptcha script."); + private onCaptchaLoaded() { + logger.log("Loaded recaptcha script."); try { - this._renderRecaptcha(DIV_ID); + this.renderRecaptcha(DIV_ID); // clear error if re-rendered this.setState({ errorText: null, @@ -128,10 +138,10 @@ export default class CaptchaForm extends React.Component { } return ( -
-

{_t( +

+

{ _t( "This homeserver would like to make sure you are not a robot.", - )}

+ ) }

{ error }
diff --git a/src/components/views/auth/CompleteSecurityBody.js b/src/components/views/auth/CompleteSecurityBody.tsx similarity index 88% rename from src/components/views/auth/CompleteSecurityBody.js rename to src/components/views/auth/CompleteSecurityBody.tsx index 6647bb1200..8f6affb64e 100644 --- a/src/components/views/auth/CompleteSecurityBody.js +++ b/src/components/views/auth/CompleteSecurityBody.tsx @@ -15,11 +15,11 @@ limitations under the License. */ import React from 'react'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.auth.CompleteSecurityBody") export default class CompleteSecurityBody extends React.PureComponent { - render() { + public render(): React.ReactNode { return
{ this.props.children }
; diff --git a/src/components/views/auth/CountryDropdown.js b/src/components/views/auth/CountryDropdown.tsx similarity index 74% rename from src/components/views/auth/CountryDropdown.js rename to src/components/views/auth/CountryDropdown.tsx index e21f112865..eb5b27be9d 100644 --- a/src/components/views/auth/CountryDropdown.js +++ b/src/components/views/auth/CountryDropdown.tsx @@ -15,21 +15,19 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; - -import {COUNTRIES, getEmojiFlag} from '../../../phonenumber'; +import { COUNTRIES, getEmojiFlag, PhoneNumberCountryDefinition } from '../../../phonenumber'; import SdkConfig from "../../../SdkConfig"; import { _t } from "../../../languageHandler"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Dropdown from "../elements/Dropdown"; const COUNTRIES_BY_ISO2 = {}; for (const c of COUNTRIES) { COUNTRIES_BY_ISO2[c.iso2] = c; } -function countryMatchesSearchQuery(query, country) { +function countryMatchesSearchQuery(query: string, country: PhoneNumberCountryDefinition): boolean { // Remove '+' if present (when searching for a prefix) if (query[0] === '+') { query = query.slice(1); @@ -41,15 +39,26 @@ function countryMatchesSearchQuery(query, country) { return false; } -@replaceableComponent("views.auth.CountryDropdown") -export default class CountryDropdown extends React.Component { - constructor(props) { - super(props); - this._onSearchChange = this._onSearchChange.bind(this); - this._onOptionChange = this._onOptionChange.bind(this); - this._getShortOption = this._getShortOption.bind(this); +interface IProps { + value?: string; + onOptionChange: (country: PhoneNumberCountryDefinition) => void; + isSmall: boolean; // if isSmall, show +44 in the selected value + showPrefix: boolean; + className?: string; + disabled?: boolean; +} - let defaultCountry = COUNTRIES[0]; +interface IState { + searchQuery: string; + defaultCountry: PhoneNumberCountryDefinition; +} + +@replaceableComponent("views.auth.CountryDropdown") +export default class CountryDropdown extends React.Component { + constructor(props: IProps) { + super(props); + + let defaultCountry: PhoneNumberCountryDefinition = COUNTRIES[0]; const defaultCountryCode = SdkConfig.get()["defaultCountryCode"]; if (defaultCountryCode) { const country = COUNTRIES.find(c => c.iso2 === defaultCountryCode.toUpperCase()); @@ -62,7 +71,7 @@ export default class CountryDropdown extends React.Component { }; } - componentDidMount() { + public componentDidMount(): void { if (!this.props.value) { // If no value is given, we start with the default // country selected, but our parent component @@ -71,21 +80,21 @@ export default class CountryDropdown extends React.Component { } } - _onSearchChange(search) { + private onSearchChange = (search: string): void => { this.setState({ searchQuery: search, }); - } + }; - _onOptionChange(iso2) { + private onOptionChange = (iso2: string): void => { this.props.onOptionChange(COUNTRIES_BY_ISO2[iso2]); - } + }; - _flagImgForIso2(iso2) { + private flagImgForIso2(iso2: string): React.ReactNode { return
{ getEmojiFlag(iso2) }
; } - _getShortOption(iso2) { + private getShortOption = (iso2: string): React.ReactNode => { if (!this.props.isSmall) { return undefined; } @@ -94,14 +103,12 @@ export default class CountryDropdown extends React.Component { countryPrefix = '+' + COUNTRIES_BY_ISO2[iso2].prefix; } return - { this._flagImgForIso2(iso2) } + { this.flagImgForIso2(iso2) } { countryPrefix } ; - } - - render() { - const Dropdown = sdk.getComponent('elements.Dropdown'); + }; + public render(): React.ReactNode { let displayedCountries; if (this.state.searchQuery) { displayedCountries = COUNTRIES.filter( @@ -124,7 +131,7 @@ export default class CountryDropdown extends React.Component { const options = displayedCountries.map((country) => { return
- { this._flagImgForIso2(country.iso2) } + { this.flagImgForIso2(country.iso2) } { _t(country.name) } (+{ country.prefix })
; }); @@ -136,10 +143,10 @@ export default class CountryDropdown extends React.Component { return ; } } - -CountryDropdown.propTypes = { - className: PropTypes.string, - isSmall: PropTypes.bool, - // if isSmall, show +44 in the selected value - showPrefix: PropTypes.bool, - onOptionChange: PropTypes.func.isRequired, - value: PropTypes.string, - disabled: PropTypes.bool, -}; diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index e819e1e59c..5544810a03 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -17,15 +17,19 @@ limitations under the License. import React, { ChangeEvent, createRef, FormEvent, MouseEvent } from 'react'; import classNames from 'classnames'; import { MatrixClient } from "matrix-js-sdk/src/client"; +import { AuthType, IAuthDict, IInputs, IStageStatus } from 'matrix-js-sdk/src/interactive-auth'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; import AccessibleButton from "../elements/AccessibleButton"; import Spinner from "../elements/Spinner"; import CountlyAnalytics from "../../../CountlyAnalytics"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; import { LocalisedPolicy, Policies } from '../../../Terms'; +import Field from '../elements/Field'; +import CaptchaForm from "./CaptchaForm"; + +import { logger } from "matrix-js-sdk/src/logger"; /* This file contains a collection of components which are used by the * InteractiveAuth to prompt the user to enter the information needed @@ -40,7 +44,7 @@ import { LocalisedPolicy, Policies } from '../../../Terms'; * one HS whilst beign a guest on another). * loginType: the login type of the auth stage being attempted * authSessionId: session id from the server - * clientSecret: The client secret in use for ID server auth sessions + * clientSecret: The client secret in use for identity server auth sessions * stageParams: params from the server for the stage being attempted * errorText: error message from a previous attempt to authenticate * submitAuthDict: a function which will be called with the new auth dict @@ -53,8 +57,8 @@ import { LocalisedPolicy, Policies } from '../../../Terms'; * Defined keys for stages are: * m.login.email.identity: * * emailSid: string representing the sid of the active - * verification session from the ID server, or - * null if no session is active. + * verification session from the identity server, + * or null if no session is active. * fail: a function which should be called with an error object if an * error occurred during the auth stage. This will cause the auth * session to be failed and the process to go back to the start. @@ -73,33 +77,6 @@ import { LocalisedPolicy, Policies } from '../../../Terms'; * focus: set the input focus appropriately in the form. */ -enum AuthType { - Password = "m.login.password", - Recaptcha = "m.login.recaptcha", - Terms = "m.login.terms", - Email = "m.login.email.identity", - Msisdn = "m.login.msisdn", - Sso = "m.login.sso", - SsoUnstable = "org.matrix.login.sso", -} - -/* eslint-disable camelcase */ -interface IAuthDict { - type?: AuthType; - // TODO: Remove `user` once servers support proper UIA - // See https://github.com/vector-im/element-web/issues/10312 - user?: string; - identifier?: any; - password?: string; - response?: string; - // TODO: Remove `threepid_creds` once servers support proper UIA - // See https://github.com/vector-im/element-web/issues/10312 - // See https://github.com/matrix-org/matrix-doc/issues/2220 - threepid_creds?: any; - threepidCreds?: any; -} -/* eslint-enable camelcase */ - export const DEFAULT_PHASE = 0; interface IAuthEntryProps { @@ -164,8 +141,7 @@ export class PasswordAuthEntry extends React.Component; + submitButtonOrSpinner = ; } else { submitButtonOrSpinner = (

{ _t("Confirm your identity by entering your account password below.") }

@@ -236,13 +210,11 @@ export class RecaptchaAuthEntry extends React.Component; + return ; } let errorText = this.props.errorText; - const CaptchaForm = sdk.getComponent("views.auth.CaptchaForm"); let sitePublicKey; if (!this.props.stageParams || !this.props.stageParams.public_key) { errorText = _t( @@ -354,7 +326,6 @@ export class TermsAuthEntry extends React.Component { @@ -382,17 +353,16 @@ export class TermsAuthEntry extends React.Component; + return ; } const checkboxes = []; @@ -422,13 +392,15 @@ export class TermsAuthEntry extends React.Component{_t("Accept")}; + submitButton = ; } return (
-

{_t("Please review and accept the policies of this homeserver:")}

+

{ _t("Please review and accept the policies of this homeserver:") }

{ checkboxes } { errorSection } { submitButton } @@ -518,11 +490,11 @@ export class MsisdnAuthEntry extends React.Component { this.props.fail(e); }).finally(() => { - this.setState({requestingToken: false}); + this.setState({ requestingToken: false }); }); } @@ -585,14 +557,13 @@ export class MsisdnAuthEntry extends React.Component; + return ; } else { const enableSubmit = Boolean(this.state.token); const submitClasses = classNames({ @@ -620,15 +591,17 @@ export class MsisdnAuthEntry extends React.Component
- - {errorSection} + { errorSection }
); @@ -710,7 +683,7 @@ export class SSOAuthEntry extends React.Component{_t("Cancel")} + >{ _t("Cancel") } ); if (this.state.phase === SSOAuthEntry.PHASE_PREAUTH) { continueButton = ( {this.props.continueText || _t("Single Sign On")} + >{ this.props.continueText || _t("Single Sign On") } ); } else { continueButton = ( {this.props.continueText || _t("Confirm")} + >{ this.props.continueText || _t("Confirm") } ); } @@ -760,8 +733,8 @@ export class SSOAuthEntry extends React.Component { errorSection }
- {cancelButton} - {continueButton} + { cancelButton } + { continueButton }
; } @@ -832,13 +805,32 @@ export class FallbackAuthEntry extends React.Component { { _t("Start authentication") } - {errorSection} + { errorSection }
); } } -export default function getEntryComponentForLoginType(loginType: AuthType): typeof React.Component { +export interface IStageComponentProps extends IAuthEntryProps { + clientSecret?: string; + stageParams?: Record; + inputs?: IInputs; + stageState?: IStageStatus; + showContinue?: boolean; + continueText?: string; + continueKind?: string; + fail?(e: Error): void; + setEmailSid?(sid: string): void; + onCancel?(): void; +} + +export interface IStageComponent extends React.ComponentClass> { + tryContinue?(): void; + attemptFailed?(): void; + focus?(): void; +} + +export default function getEntryComponentForLoginType(loginType: AuthType): IStageComponent { switch (loginType) { case AuthType.Password: return PasswordAuthEntry; diff --git a/src/components/views/auth/LanguageSelector.js b/src/components/views/auth/LanguageSelector.tsx similarity index 76% rename from src/components/views/auth/LanguageSelector.js rename to src/components/views/auth/LanguageSelector.tsx index 0738ee43e4..c26b4797f3 100644 --- a/src/components/views/auth/LanguageSelector.js +++ b/src/components/views/auth/LanguageSelector.tsx @@ -15,24 +15,26 @@ limitations under the License. */ import SdkConfig from "../../../SdkConfig"; -import {getCurrentLanguage} from "../../../languageHandler"; +import { getCurrentLanguage } from "../../../languageHandler"; import SettingsStore from "../../../settings/SettingsStore"; import PlatformPeg from "../../../PlatformPeg"; -import * as sdk from '../../../index'; import React from 'react'; -import {SettingLevel} from "../../../settings/SettingLevel"; +import { SettingLevel } from "../../../settings/SettingLevel"; +import LanguageDropdown from "../elements/LanguageDropdown"; -function onChange(newLang) { +function onChange(newLang: string): void { if (getCurrentLanguage() !== newLang) { SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLang); PlatformPeg.get().reload(); } } -export default function LanguageSelector({disabled}) { - if (SdkConfig.get()['disable_login_language_selector']) return
; +interface IProps { + disabled?: boolean; +} - const LanguageDropdown = sdk.getComponent('views.elements.LanguageDropdown'); +export default function LanguageSelector({ disabled }: IProps): JSX.Element { + if (SdkConfig.get()['disable_login_language_selector']) return
; return { autoFocus?: boolean; diff --git a/src/components/views/auth/PasswordLogin.tsx b/src/components/views/auth/PasswordLogin.tsx index 2a42804a61..587d7f2453 100644 --- a/src/components/views/auth/PasswordLogin.tsx +++ b/src/components/views/auth/PasswordLogin.tsx @@ -19,14 +19,14 @@ import classNames from 'classnames'; import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; -import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; +import { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils"; import AccessibleButton from "../elements/AccessibleButton"; import CountlyAnalytics from "../../../CountlyAnalytics"; import withValidation from "../elements/Validation"; import * as Email from "../../../email"; import Field from "../elements/Field"; import CountryDropdown from "./CountryDropdown"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; // For validating phone numbers without country codes const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; @@ -52,8 +52,8 @@ interface IProps { interface IState { fieldValid: Partial>; - loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone, - password: "", + loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone; + password: ""; } enum LoginField { @@ -166,7 +166,7 @@ export default class PasswordLogin extends React.PureComponent { }; private onPasswordChanged = ev => { - this.setState({password: ev.target.value}); + this.setState({ password: ev.target.value }); }; private async verifyFieldsBeforeSubmit() { @@ -322,7 +322,7 @@ export default class PasswordLogin extends React.PureComponent { const result = await this.validatePasswordRules(fieldState); this.markFieldValid(LoginField.Password, result.valid); return result; - } + }; private renderLoginField(loginType: IState["loginType"], autoFocus: boolean) { const classes = { @@ -416,7 +416,7 @@ export default class PasswordLogin extends React.PureComponent { kind="link" onClick={this.onForgotPasswordClick} > - {_t("Forgot password?")} + { _t("Forgot password?") } ; } @@ -441,16 +441,16 @@ export default class PasswordLogin extends React.PureComponent { disabled={this.props.disableSubmit} >
@@ -460,8 +460,8 @@ export default class PasswordLogin extends React.PureComponent { return (
- {loginType} - {loginField} + { loginType } + { loginField } { onValidate={this.onPasswordValidate} ref={field => this[LoginField.Password] = field} /> - {forgotPasswordJsx} + { forgotPasswordJsx } { !this.props.busy &&
- {this.renderUsername()} + { this.renderUsername() }
- {this.renderPassword()} - {this.renderPasswordConfirm()} + { this.renderPassword() } + { this.renderPasswordConfirm() }
- {this.renderEmail()} - {this.renderPhoneNumber()} + { this.renderEmail() } + { this.renderPhoneNumber() }
{ emailHelperText } { registerButton } diff --git a/src/components/views/auth/Welcome.js b/src/components/views/auth/Welcome.tsx similarity index 76% rename from src/components/views/auth/Welcome.js rename to src/components/views/auth/Welcome.tsx index fca66fcf9b..0e12025fbd 100644 --- a/src/components/views/auth/Welcome.js +++ b/src/components/views/auth/Welcome.tsx @@ -17,29 +17,34 @@ limitations under the License. import React from 'react'; import classNames from "classnames"; -import * as sdk from '../../../index'; +import * as sdk from "../../../index"; import SdkConfig from '../../../SdkConfig'; import AuthPage from "./AuthPage"; -import {_td} from "../../../languageHandler"; +import { _td } from "../../../languageHandler"; import SettingsStore from "../../../settings/SettingsStore"; -import {UIFeature} from "../../../settings/UIFeature"; +import { UIFeature } from "../../../settings/UIFeature"; import CountlyAnalytics from "../../../CountlyAnalytics"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import LanguageSelector from "./LanguageSelector"; // translatable strings for Welcome pages _td("Sign in with SSO"); +interface IProps { + +} + @replaceableComponent("views.auth.Welcome") -export default class Welcome extends React.PureComponent { - constructor(props) { +export default class Welcome extends React.PureComponent { + constructor(props: IProps) { super(props); CountlyAnalytics.instance.track("onboarding_welcome"); } - render() { - const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage'); - const LanguageSelector = sdk.getComponent('auth.LanguageSelector'); + public render(): React.ReactNode { + // FIXME: Using an import will result in wrench-element-tests failures + const EmbeddedPage = sdk.getComponent("structures.EmbeddedPage"); const pagesConfig = SdkConfig.get().embeddedPages; let pageUrl = null; diff --git a/src/components/views/avatars/BaseAvatar.tsx b/src/components/views/avatars/BaseAvatar.tsx index 8ce05e0a55..6aaef29854 100644 --- a/src/components/views/avatars/BaseAvatar.tsx +++ b/src/components/views/avatars/BaseAvatar.tsx @@ -17,15 +17,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useCallback, useContext, useEffect, useState} from 'react'; +import React, { useCallback, useContext, useEffect, useState } from 'react'; import classNames from 'classnames'; +import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials'; + import * as AvatarLogic from '../../../Avatar'; import SettingsStore from "../../../settings/SettingsStore"; import AccessibleButton from '../elements/AccessibleButton'; +import RoomContext from "../../../contexts/RoomContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {useEventEmitter} from "../../../hooks/useEventEmitter"; -import {toPx} from "../../../utils/units"; -import {ResizeMethod} from "../../../Avatar"; +import { useEventEmitter } from "../../../hooks/useEventEmitter"; +import { toPx } from "../../../utils/units"; import { _t } from '../../../languageHandler'; interface IProps { @@ -44,12 +46,12 @@ interface IProps { className?: string; } -const calculateUrls = (url, urls) => { +const calculateUrls = (url, urls, lowBandwidth) => { // work out the full set of urls to try to load. This is formed like so: // imageUrls: [ props.url, ...props.urls ] let _urls = []; - if (!SettingsStore.getValue("lowBandwidth")) { + if (!lowBandwidth) { _urls = urls || []; if (url) { @@ -62,8 +64,14 @@ const calculateUrls = (url, urls) => { return Array.from(new Set(_urls)); }; -const useImageUrl = ({url, urls}): [string, () => void] => { - const [imageUrls, setUrls] = useState(calculateUrls(url, urls)); +const useImageUrl = ({ url, urls }): [string, () => void] => { + // Since this is a hot code path and the settings store can be slow, we + // use the cached lowBandwidth value from the room context if it exists + const roomContext = useContext(RoomContext); + const lowBandwidth = roomContext ? + roomContext.lowBandwidth : SettingsStore.getValue("lowBandwidth"); + + const [imageUrls, setUrls] = useState(calculateUrls(url, urls, lowBandwidth)); const [urlsIndex, setIndex] = useState(0); const onError = useCallback(() => { @@ -71,7 +79,7 @@ const useImageUrl = ({url, urls}): [string, () => void] => { }, []); useEffect(() => { - setUrls(calculateUrls(url, urls)); + setUrls(calculateUrls(url, urls, lowBandwidth)); setIndex(0); }, [url, JSON.stringify(urls)]); // eslint-disable-line react-hooks/exhaustive-deps @@ -107,7 +115,7 @@ const BaseAvatar = (props: IProps) => { ...otherProps } = props; - const [imageUrl, onError] = useImageUrl({url, urls}); + const [imageUrl, onError] = useImageUrl({ url, urls }); if (!imageUrl && defaultToInitialLetter) { const initialLetter = AvatarLogic.getInitialLetter(name); @@ -179,7 +187,8 @@ const BaseAvatar = (props: IProps) => { width: toPx(width), height: toPx(height), }} - title={title} alt={_t("Avatar")} + title={title} + alt={_t("Avatar")} inputRef={inputRef} {...otherProps} /> ); @@ -193,7 +202,8 @@ const BaseAvatar = (props: IProps) => { width: toPx(width), height: toPx(height), }} - title={title} alt="" + title={title} + alt="" ref={inputRef} {...otherProps} /> ); diff --git a/src/components/views/avatars/DecoratedRoomAvatar.tsx b/src/components/views/avatars/DecoratedRoomAvatar.tsx index 42aef24086..99f2b70efc 100644 --- a/src/components/views/avatars/DecoratedRoomAvatar.tsx +++ b/src/components/views/avatars/DecoratedRoomAvatar.tsx @@ -24,19 +24,20 @@ import RoomAvatar from "./RoomAvatar"; import NotificationBadge from '../rooms/NotificationBadge'; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { NotificationState } from "../../../stores/notifications/NotificationState"; -import {isPresenceEnabled} from "../../../utils/presence"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import {_t} from "../../../languageHandler"; +import { isPresenceEnabled } from "../../../utils/presence"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { _t } from "../../../languageHandler"; import TextWithTooltip from "../elements/TextWithTooltip"; import DMRoomMap from "../../../utils/DMRoomMap"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { IOOBData } from "../../../stores/ThreepidInviteStore"; interface IProps { room: Room; avatarSize: number; displayBadge?: boolean; forceCount?: boolean; - oobData?: object; + oobData?: IOOBData; viewAvatarOnClick?: boolean; } @@ -121,7 +122,7 @@ export default class DecoratedRoomAvatar extends React.PureComponent - {icon} - {badge} + { icon } + { badge }
; } } diff --git a/src/components/views/avatars/GroupAvatar.tsx b/src/components/views/avatars/GroupAvatar.tsx index 3734ba9504..5df58555c4 100644 --- a/src/components/views/avatars/GroupAvatar.tsx +++ b/src/components/views/avatars/GroupAvatar.tsx @@ -15,10 +15,11 @@ limitations under the License. */ import React from 'react'; +import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials'; + import BaseAvatar from './BaseAvatar'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {mediaFromMxc} from "../../../customisations/Media"; -import {ResizeMethod} from "../../../Avatar"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../../customisations/Media"; export interface IProps { groupId?: string; @@ -51,7 +52,7 @@ export default class GroupAvatar extends React.Component { // extract the props we use from props so we can pass any others through // should consider adding this as a global rule in js-sdk? /* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ - const {groupId, groupAvatarUrl, groupName, ...otherProps} = this.props; + const { groupId, groupAvatarUrl, groupName, ...otherProps } = this.props; return ( , "name" | "idName" | "url"> { member: RoomMember; @@ -33,9 +33,10 @@ interface IProps extends Omit, "name" | resizeMethod?: ResizeMethod; // The onClick to give the avatar onClick?: React.MouseEventHandler; - // Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser` + // Whether the onClick of the avatar should be overridden to dispatch `Action.ViewUser` viewUserOnClick?: boolean; title?: string; + style?: any; } interface IState { @@ -89,7 +90,7 @@ export default class MemberAvatar extends React.Component { } render() { - let {member, fallbackUserId, onClick, viewUserOnClick, ...otherProps} = this.props; + let { member, fallbackUserId, onClick, viewUserOnClick, ...otherProps } = this.props; const userId = member ? member.userId : fallbackUserId; if (viewUserOnClick) { @@ -102,8 +103,12 @@ export default class MemberAvatar extends React.Component { } return ( - + ); } } diff --git a/src/components/views/avatars/MemberStatusMessageAvatar.js b/src/components/views/avatars/MemberStatusMessageAvatar.tsx similarity index 71% rename from src/components/views/avatars/MemberStatusMessageAvatar.js rename to src/components/views/avatars/MemberStatusMessageAvatar.tsx index acf190f17f..8c703b3b32 100644 --- a/src/components/views/avatars/MemberStatusMessageAvatar.js +++ b/src/components/views/avatars/MemberStatusMessageAvatar.tsx @@ -14,44 +14,49 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from 'react'; -import PropTypes from 'prop-types'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import {_t} from "../../../languageHandler"; +import React, { createRef } from 'react'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import { _t } from "../../../languageHandler"; import MemberAvatar from '../avatars/MemberAvatar'; import classNames from 'classnames'; import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu"; import SettingsStore from "../../../settings/SettingsStore"; -import {ContextMenu, ContextMenuButton} from "../../structures/ContextMenu"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { ChevronFace, ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { ResizeMethod } from "matrix-js-sdk/src/@types/partials"; + +interface IProps { + member: RoomMember; + width?: number; + height?: number; + resizeMethod?: ResizeMethod; +} + +interface IState { + hasStatus: boolean; + menuDisplayed: boolean; +} @replaceableComponent("views.avatars.MemberStatusMessageAvatar") -export default class MemberStatusMessageAvatar extends React.Component { - static propTypes = { - member: PropTypes.object.isRequired, - width: PropTypes.number, - height: PropTypes.number, - resizeMethod: PropTypes.string, - }; - - static defaultProps = { +export default class MemberStatusMessageAvatar extends React.Component { + public static defaultProps: Partial = { width: 40, height: 40, resizeMethod: 'crop', }; + private button = createRef(); - constructor(props) { + constructor(props: IProps) { super(props); this.state = { hasStatus: this.hasStatus, menuDisplayed: false, }; - - this._button = createRef(); } - componentDidMount() { + public componentDidMount(): void { if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) { throw new Error("Cannot use MemberStatusMessageAvatar on anyone but the logged in user"); } @@ -62,44 +67,44 @@ export default class MemberStatusMessageAvatar extends React.Component { if (!user) { return; } - user.on("User._unstable_statusMessage", this._onStatusMessageCommitted); + user.on("User._unstable_statusMessage", this.onStatusMessageCommitted); } - componentWillUnmount() { + public componentWillUnmount(): void { const { user } = this.props.member; if (!user) { return; } user.removeListener( "User._unstable_statusMessage", - this._onStatusMessageCommitted, + this.onStatusMessageCommitted, ); } - get hasStatus() { + private get hasStatus(): boolean { const { user } = this.props.member; if (!user) { return false; } - return !!user._unstable_statusMessage; + return !!user.unstable_statusMessage; } - _onStatusMessageCommitted = () => { + private onStatusMessageCommitted = (): void => { // The `User` object has observed a status message change. this.setState({ hasStatus: this.hasStatus, }); }; - openMenu = () => { + private openMenu = (): void => { this.setState({ menuDisplayed: true }); }; - closeMenu = () => { + private closeMenu = (): void => { this.setState({ menuDisplayed: false }); }; - render() { + public render(): JSX.Element { const avatar = - + ); } @@ -140,12 +145,12 @@ export default class MemberStatusMessageAvatar extends React.Component { return - {avatar} + { avatar } { contextMenu } diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index 4693d907ba..f285222f7b 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -13,29 +13,35 @@ 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 Room from 'matrix-js-sdk/src/models/room'; + +import React, { ComponentProps } from 'react'; +import { Room } from 'matrix-js-sdk/src/models/room'; +import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials'; +import classNames from "classnames"; import BaseAvatar from './BaseAvatar'; import ImageView from '../elements/ImageView'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; import Modal from '../../../Modal'; import * as Avatar from '../../../Avatar'; -import {ResizeMethod} from "../../../Avatar"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {mediaFromMxc} from "../../../customisations/Media"; +import DMRoomMap from "../../../utils/DMRoomMap"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../../customisations/Media"; +import { IOOBData } from '../../../stores/ThreepidInviteStore'; interface IProps extends Omit, "name" | "idName" | "url" | "onClick"> { // Room may be left unset here, but if it is, // oobData.avatarUrl should be set (else there // would be nowhere to get the avatar from) room?: Room; - // TODO: type when js-sdk has types - oobData?: any; + oobData?: IOOBData & { + roomId?: string; + }; width?: number; height?: number; resizeMethod?: ResizeMethod; viewAvatarOnClick?: boolean; + className?: string; onClick?(): void; } @@ -128,14 +134,21 @@ export default class RoomAvatar extends React.Component { }; public render() { - const {room, oobData, viewAvatarOnClick, onClick, ...otherProps} = this.props; + const { room, oobData, viewAvatarOnClick, onClick, className, ...otherProps } = this.props; const roomName = room ? room.name : oobData.name; + // 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 + const idName = room ? (DMRoomMap.shared().getUserIdForRoomId(room.roomId) ?? room.roomId) : oobData.roomId; return ( - diff --git a/src/components/views/avatars/WidgetAvatar.tsx b/src/components/views/avatars/WidgetAvatar.tsx index cca158269e..264f1f3956 100644 --- a/src/components/views/avatars/WidgetAvatar.tsx +++ b/src/components/views/avatars/WidgetAvatar.tsx @@ -14,12 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {ComponentProps} from 'react'; +import React, { ComponentProps } from 'react'; import classNames from 'classnames'; -import {IApp} from "../../../stores/WidgetStore"; -import BaseAvatar, {BaseAvatarType} from "./BaseAvatar"; -import {mediaFromMxc} from "../../../customisations/Media"; +import { IApp } from "../../../stores/WidgetStore"; +import BaseAvatar, { BaseAvatarType } from "./BaseAvatar"; +import { mediaFromMxc } from "../../../customisations/Media"; interface IProps extends Omit, "name" | "url" | "urls"> { app: IApp; @@ -49,7 +49,7 @@ const WidgetAvatar: React.FC = ({ app, className, width = 20, height = 2 width={width} height={height} /> - ) + ); }; export default WidgetAvatar; diff --git a/src/components/views/beta/BetaCard.tsx b/src/components/views/beta/BetaCard.tsx index 821c448f4f..c2ba869ab4 100644 --- a/src/components/views/beta/BetaCard.tsx +++ b/src/components/views/beta/BetaCard.tsx @@ -17,14 +17,17 @@ limitations under the License. import React from "react"; import classNames from "classnames"; -import {_t} from "../../../languageHandler"; +import { _t } from "../../../languageHandler"; import AccessibleButton from "../elements/AccessibleButton"; import SettingsStore from "../../../settings/SettingsStore"; -import {SettingLevel} from "../../../settings/SettingLevel"; +import { SettingLevel } from "../../../settings/SettingLevel"; import TextWithTooltip from "../elements/TextWithTooltip"; import Modal from "../../../Modal"; import BetaFeedbackDialog from "../dialogs/BetaFeedbackDialog"; import SdkConfig from "../../../SdkConfig"; +import SettingsFlag from "../elements/SettingsFlag"; + +// XXX: Keep this around for re-use in future Betas interface IProps { title?: string; @@ -66,7 +69,7 @@ const BetaCard = ({ title: titleOverride, featureId }: IProps) => { const info = SettingsStore.getBetaInfo(featureId); if (!info) return null; // Beta is invalid/disabled - const { title, caption, disclaimer, image, feedbackLabel, feedbackSubheading } = info; + const { title, caption, disclaimer, image, feedbackLabel, feedbackSubheading, extraSettings } = info; const value = SettingsStore.getValue(featureId); let feedbackButton; @@ -82,26 +85,33 @@ const BetaCard = ({ title: titleOverride, featureId }: IProps) => { } return
-
-

- { titleOverride || _t(title) } - -

- { _t(caption) } +
- { feedbackButton } - SettingsStore.setValue(featureId, null, SettingLevel.DEVICE, !value)} - kind={feedbackButton ? "primary_outline" : "primary"} - > - { value ? _t("Leave the beta") : _t("Join the beta") } - +

+ { titleOverride || _t(title) } + +

+ { _t(caption) } +
+ { feedbackButton } + SettingsStore.setValue(featureId, null, SettingLevel.DEVICE, !value)} + kind={feedbackButton ? "primary_outline" : "primary"} + > + { value ? _t("Leave the beta") : _t("Join the beta") } + +
+ { disclaimer &&
+ { disclaimer(value) } +
}
- { disclaimer &&
- { disclaimer(value) } -
} +
- + { extraSettings && value &&
+ { extraSettings.map(key => ( + + )) } +
}
; }; diff --git a/src/components/views/context_menus/CallContextMenu.tsx b/src/components/views/context_menus/CallContextMenu.tsx index 97473059a6..a61cdeedd3 100644 --- a/src/components/views/context_menus/CallContextMenu.tsx +++ b/src/components/views/context_menus/CallContextMenu.tsx @@ -22,7 +22,7 @@ import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import CallHandler from '../../../CallHandler'; import InviteDialog, { KIND_CALL_TRANSFER } from '../dialogs/InviteDialog'; import Modal from '../../../Modal'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps extends IContextMenuProps { call: MatrixCall; @@ -42,21 +42,21 @@ export default class CallContextMenu extends React.Component { onHoldClick = () => { this.props.call.setRemoteOnHold(true); this.props.onFinished(); - } + }; onUnholdClick = () => { CallHandler.sharedInstance().setActiveCallRoomId(this.props.call.roomId); this.props.onFinished(); - } + }; onTransferClick = () => { Modal.createTrackedDialog( - 'Transfer Call', '', InviteDialog, {kind: KIND_CALL_TRANSFER, call: this.props.call}, - /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, + 'Transfer Call', '', InviteDialog, { kind: KIND_CALL_TRANSFER, call: this.props.call }, + /*className=*/"mx_InviteDialog_transferWrapper", /*isPriority=*/false, /*isStatic=*/true, ); this.props.onFinished(); - } + }; render() { const holdUnholdCaption = this.props.call.isRemoteOnHold() ? _t("Resume") : _t("Hold"); @@ -65,15 +65,15 @@ export default class CallContextMenu extends React.Component { let transferItem; if (this.props.call.opponentCanBeTransferred()) { transferItem = - {_t("Transfer")} + { _t("Transfer") } ; } return - {holdUnholdCaption} + { holdUnholdCaption } - {transferItem} + { transferItem } ; } } diff --git a/src/components/views/context_menus/DialpadContextMenu.tsx b/src/components/views/context_menus/DialpadContextMenu.tsx index 17abce0c61..01c7c6c1d8 100644 --- a/src/components/views/context_menus/DialpadContextMenu.tsx +++ b/src/components/views/context_menus/DialpadContextMenu.tsx @@ -14,12 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import { _t } from '../../../languageHandler'; +import * as React from "react"; +import { createRef } from "react"; +import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu'; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; -import Dialpad from '../voip/DialPad'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import Field from "../elements/Field"; +import DialPad from '../voip/DialPad'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps extends IContextMenuProps { call: MatrixCall; @@ -31,30 +33,62 @@ interface IState { @replaceableComponent("views.context_menus.DialpadContextMenu") export default class DialpadContextMenu extends React.Component { + private numberEntryFieldRef: React.RefObject = createRef(); + constructor(props) { super(props); this.state = { value: '', - } + }; } - onDigitPress = (digit) => { + onDigitPress = (digit: string, ev: ButtonEvent) => { this.props.call.sendDtmfDigit(digit); - this.setState({value: this.state.value + digit}); - } + this.setState({ value: this.state.value + digit }); + + // Keep the number field focused so that keyboard entry is still available + // However, don't focus if this wasn't the result of directly clicking on the button, + // i.e someone using keyboard navigation. + if (ev.type === "click") { + this.numberEntryFieldRef.current?.focus(); + } + }; + + onCancelClick = () => { + this.props.onFinished(); + }; + + onKeyDown = (ev) => { + // Prevent Backspace and Delete keys from functioning in the entry field + if (ev.code === "Backspace" || ev.code === "Delete") { + ev.preventDefault(); + } + }; + + onChange = (ev) => { + this.setState({ value: ev.target.value }); + }; render() { return -
+
- {_t("Dial pad")} + +
+
+ +
+
+
-
{this.state.value}
-
-
-
-
; } diff --git a/src/components/views/context_menus/GenericElementContextMenu.js b/src/components/views/context_menus/GenericElementContextMenu.tsx similarity index 63% rename from src/components/views/context_menus/GenericElementContextMenu.js rename to src/components/views/context_menus/GenericElementContextMenu.tsx index e04e3f7695..a0a8c89b37 100644 --- a/src/components/views/context_menus/GenericElementContextMenu.js +++ b/src/components/views/context_menus/GenericElementContextMenu.tsx @@ -15,46 +15,41 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; -/* +interface IProps { + element: React.ReactNode; + // Function to be called when the parent window is resized + // This can be used to reposition or close the menu on resize and + // ensure that it is not displayed in a stale position. + onResize?: () => void; +} + +/** * This component can be used to display generic HTML content in a contextual * menu. */ - - @replaceableComponent("views.context_menus.GenericElementContextMenu") -export default class GenericElementContextMenu extends React.Component { - static propTypes = { - element: PropTypes.element.isRequired, - // Function to be called when the parent window is resized - // This can be used to reposition or close the menu on resize and - // ensure that it is not displayed in a stale position. - onResize: PropTypes.func, - }; - - constructor(props) { +export default class GenericElementContextMenu extends React.Component { + constructor(props: IProps) { super(props); - this.resize = this.resize.bind(this); } - componentDidMount() { - this.resize = this.resize.bind(this); + public componentDidMount(): void { window.addEventListener("resize", this.resize); } - componentWillUnmount() { + public componentWillUnmount(): void { window.removeEventListener("resize", this.resize); } - resize() { + private resize = (): void => { if (this.props.onResize) { this.props.onResize(); } - } + }; - render() { + public render(): JSX.Element { return
{ this.props.element }
; } } diff --git a/src/components/views/context_menus/GenericTextContextMenu.js b/src/components/views/context_menus/GenericTextContextMenu.tsx similarity index 79% rename from src/components/views/context_menus/GenericTextContextMenu.js rename to src/components/views/context_menus/GenericTextContextMenu.tsx index 3d3add006f..3ca158dd02 100644 --- a/src/components/views/context_menus/GenericTextContextMenu.js +++ b/src/components/views/context_menus/GenericTextContextMenu.tsx @@ -15,16 +15,15 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +interface IProps { + message: string; +} @replaceableComponent("views.context_menus.GenericTextContextMenu") -export default class GenericTextContextMenu extends React.Component { - static propTypes = { - message: PropTypes.string.isRequired, - }; - - render() { +export default class GenericTextContextMenu extends React.Component { + public render(): JSX.Element { return
{ this.props.message }
; } } diff --git a/src/components/views/context_menus/GroupInviteTileContextMenu.js b/src/components/views/context_menus/GroupInviteTileContextMenu.js index 15078326b3..1529723ac8 100644 --- a/src/components/views/context_menus/GroupInviteTileContextMenu.js +++ b/src/components/views/context_menus/GroupInviteTileContextMenu.js @@ -20,10 +20,10 @@ import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import Modal from '../../../Modal'; -import {Group} from 'matrix-js-sdk/src/models/group'; +import { Group } from 'matrix-js-sdk/src/models/group'; import GroupStore from "../../../stores/GroupStore"; -import {MenuItem} from "../../structures/ContextMenu"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { MenuItem } from "../../structures/ContextMenu"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.context_menus.GroupInviteTileContextMenu") export default class GroupInviteTileContextMenu extends React.Component { diff --git a/src/components/views/context_menus/IconizedContextMenu.tsx b/src/components/views/context_menus/IconizedContextMenu.tsx index a3fb00a9f4..571b0b39bf 100644 --- a/src/components/views/context_menus/IconizedContextMenu.tsx +++ b/src/components/views/context_menus/IconizedContextMenu.tsx @@ -64,8 +64,8 @@ export const IconizedContextMenuRadio: React.FC = ({ label={label} > - {label} - {active && } + { label } + { active && } ; }; @@ -85,30 +85,34 @@ export const IconizedContextMenuCheckbox: React.FC = ({ label={label} > - {label} - {active && } + { label } + ; }; -export const IconizedContextMenuOption: React.FC = ({label, iconClassName, ...props}) => { +export const IconizedContextMenuOption: React.FC = ({ label, iconClassName, children, ...props }) => { return { iconClassName && } - {label} + { label } + { children } ; }; -export const IconizedContextMenuOptionList: React.FC = ({first, red, className, children}) => { +export const IconizedContextMenuOptionList: React.FC = ({ first, red, className, children }) => { const classes = classNames("mx_IconizedContextMenu_optionList", className, { mx_IconizedContextMenu_optionList_notFirst: !first, mx_IconizedContextMenu_optionList_red: red, }); return
- {children} + { children }
; }; -const IconizedContextMenu: React.FC = ({className, children, compact, ...props}) => { +const IconizedContextMenu: React.FC = ({ className, children, compact, ...props }) => { const classes = classNames("mx_IconizedContextMenu", className, { mx_IconizedContextMenu_compact: compact, }); diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js deleted file mode 100644 index 594b98b1f5..0000000000 --- a/src/components/views/context_menus/MessageContextMenu.js +++ /dev/null @@ -1,390 +0,0 @@ -/* -Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2015, 2016, 2018, 2019, 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 from 'react'; -import PropTypes from 'prop-types'; -import { EventStatus } from 'matrix-js-sdk/src/models/event'; - -import { MatrixClientPeg } from '../../../MatrixClientPeg'; -import dis from '../../../dispatcher/dispatcher'; -import * as sdk from '../../../index'; -import { _t } from '../../../languageHandler'; -import Modal from '../../../Modal'; -import Resend from '../../../Resend'; -import SettingsStore from '../../../settings/SettingsStore'; -import { isUrlPermitted } from '../../../HtmlUtils'; -import { isContentActionable } from '../../../utils/EventUtils'; -import { MenuItem } from "../../structures/ContextMenu"; -import { EventType } from "matrix-js-sdk/src/@types/event"; -import { replaceableComponent } from "../../../utils/replaceableComponent"; -import { ReadPinsEventId } from "../right_panel/PinnedMessagesCard"; - -export function canCancel(eventStatus) { - return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; -} - -@replaceableComponent("views.context_menus.MessageContextMenu") -export default class MessageContextMenu extends React.Component { - static propTypes = { - /* the MatrixEvent associated with the context menu */ - mxEvent: PropTypes.object.isRequired, - - /* an optional EventTileOps implementation that can be used to unhide preview widgets */ - eventTileOps: PropTypes.object, - - /* an optional function to be called when the user clicks collapse thread, if not provided hide button */ - collapseReplyThread: PropTypes.func, - - /* callback called when the menu is dismissed */ - onFinished: PropTypes.func, - - /* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */ - onCloseDialog: PropTypes.func, - }; - - state = { - canRedact: false, - canPin: false, - }; - - componentDidMount() { - MatrixClientPeg.get().on('RoomMember.powerLevel', this._checkPermissions); - this._checkPermissions(); - } - - componentWillUnmount() { - const cli = MatrixClientPeg.get(); - if (cli) { - cli.removeListener('RoomMember.powerLevel', this._checkPermissions); - } - } - - _checkPermissions = () => { - const cli = MatrixClientPeg.get(); - const room = cli.getRoom(this.props.mxEvent.getRoomId()); - - // We explicitly decline to show the redact option on ACL events as it has a potential - // to obliterate the room - https://github.com/matrix-org/synapse/issues/4042 - // Similarly for encryption events, since redacting them "breaks everything" - const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId) - && this.props.mxEvent.getType() !== EventType.RoomServerAcl - && this.props.mxEvent.getType() !== EventType.RoomEncryption; - let canPin = room.currentState.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli); - - // HACK: Intentionally say we can't pin if the user doesn't want to use the functionality - if (!SettingsStore.getValue("feature_pinning")) canPin = false; - - this.setState({canRedact, canPin}); - }; - - _isPinned() { - const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); - const pinnedEvent = room.currentState.getStateEvents(EventType.RoomPinnedEvents, ''); - if (!pinnedEvent) return false; - const content = pinnedEvent.getContent(); - return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId()); - } - - onResendReactionsClick = () => { - for (const reaction of this._getUnsentReactions()) { - Resend.resend(reaction); - } - this.closeMenu(); - }; - - onReportEventClick = () => { - const ReportEventDialog = sdk.getComponent("dialogs.ReportEventDialog"); - Modal.createTrackedDialog('Report Event', '', ReportEventDialog, { - mxEvent: this.props.mxEvent, - }, 'mx_Dialog_reportEvent'); - this.closeMenu(); - }; - - onViewSourceClick = () => { - const ViewSource = sdk.getComponent('structures.ViewSource'); - Modal.createTrackedDialog('View Event Source', '', ViewSource, { - mxEvent: this.props.mxEvent, - }, 'mx_Dialog_viewsource'); - this.closeMenu(); - }; - - onRedactClick = () => { - const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog"); - Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, { - onFinished: async (proceed, reason) => { - if (!proceed) return; - - const cli = MatrixClientPeg.get(); - try { - if (this.props.onCloseDialog) this.props.onCloseDialog(); - await cli.redactEvent( - this.props.mxEvent.getRoomId(), - this.props.mxEvent.getId(), - undefined, - reason ? { reason } : {}, - ); - } catch (e) { - const code = e.errcode || e.statusCode; - // only show the dialog if failing for something other than a network error - // (e.g. no errcode or statusCode) as in that case the redactions end up in the - // detached queue and we show the room status bar to allow retry - if (typeof code !== "undefined") { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - // display error message stating you couldn't delete this. - Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, { - title: _t('Error'), - description: _t('You cannot delete this message. (%(code)s)', {code}), - }); - } - } - }, - }, 'mx_Dialog_confirmredact'); - this.closeMenu(); - }; - - onForwardClick = () => { - if (this.props.onCloseDialog) this.props.onCloseDialog(); - dis.dispatch({ - action: 'forward_event', - event: this.props.mxEvent, - }); - this.closeMenu(); - }; - - onPinClick = () => { - const cli = MatrixClientPeg.get(); - const room = cli.getRoom(this.props.mxEvent.getRoomId()); - const eventId = this.props.mxEvent.getId(); - - const pinnedIds = room?.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.pinned || []; - if (pinnedIds.includes(eventId)) { - pinnedIds.splice(pinnedIds.indexOf(eventId), 1); - } else { - pinnedIds.push(eventId); - cli.setRoomAccountData(room.roomId, ReadPinsEventId, { - event_ids: [ - ...room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids, - eventId, - ], - }); - } - cli.sendStateEvent(this.props.mxEvent.getRoomId(), EventType.RoomPinnedEvents, { pinned: pinnedIds }, ""); - this.closeMenu(); - }; - - closeMenu = () => { - if (this.props.onFinished) this.props.onFinished(); - }; - - onUnhidePreviewClick = () => { - if (this.props.eventTileOps) { - this.props.eventTileOps.unhideWidget(); - } - this.closeMenu(); - }; - - onQuoteClick = () => { - dis.dispatch({ - action: 'quote', - event: this.props.mxEvent, - }); - this.closeMenu(); - }; - - onPermalinkClick = (e: Event) => { - e.preventDefault(); - const ShareDialog = sdk.getComponent("dialogs.ShareDialog"); - Modal.createTrackedDialog('share room message dialog', '', ShareDialog, { - target: this.props.mxEvent, - permalinkCreator: this.props.permalinkCreator, - }); - this.closeMenu(); - }; - - onCollapseReplyThreadClick = () => { - this.props.collapseReplyThread(); - this.closeMenu(); - }; - - _getReactions(filter) { - const cli = MatrixClientPeg.get(); - const room = cli.getRoom(this.props.mxEvent.getRoomId()); - const eventId = this.props.mxEvent.getId(); - return room.getPendingEvents().filter(e => { - const relation = e.getRelation(); - return relation && - relation.rel_type === "m.annotation" && - relation.event_id === eventId && - filter(e); - }); - } - - _getPendingReactions() { - return this._getReactions(e => canCancel(e.status)); - } - - _getUnsentReactions() { - return this._getReactions(e => e.status === EventStatus.NOT_SENT); - } - - render() { - const cli = MatrixClientPeg.get(); - const me = cli.getUserId(); - const mxEvent = this.props.mxEvent; - const eventStatus = mxEvent.status; - const unsentReactionsCount = this._getUnsentReactions().length; - let resendReactionsButton; - let redactButton; - let forwardButton; - let pinButton; - let unhidePreviewButton; - let externalURLButton; - let quoteButton; - let collapseReplyThread; - - // status is SENT before remote-echo, null after - const isSent = !eventStatus || eventStatus === EventStatus.SENT; - if (!mxEvent.isRedacted()) { - if (unsentReactionsCount !== 0) { - resendReactionsButton = ( - - { _t('Resend %(unsentCount)s reaction(s)', {unsentCount: unsentReactionsCount}) } - - ); - } - } - - if (isSent && this.state.canRedact) { - redactButton = ( - - { _t('Remove') } - - ); - } - - if (isContentActionable(mxEvent)) { - forwardButton = ( - - { _t('Forward Message') } - - ); - - if (this.state.canPin) { - pinButton = ( - - { this._isPinned() ? _t('Unpin Message') : _t('Pin Message') } - - ); - } - } - - const viewSourceButton = ( - - { _t('View Source') } - - ); - - if (this.props.eventTileOps) { - if (this.props.eventTileOps.isWidgetHidden()) { - unhidePreviewButton = ( - - { _t('Unhide Preview') } - - ); - } - } - - let permalink; - if (this.props.permalinkCreator) { - permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()); - } - // XXX: if we use room ID, we should also include a server where the event can be found (other than in the domain of the event ID) - const permalinkButton = ( - - { mxEvent.isRedacted() || mxEvent.getType() !== 'm.room.message' - ? _t('Share Permalink') : _t('Share Message') } - - ); - - if (this.props.eventTileOps) { // this event is rendered using TextualBody - quoteButton = ( - - { _t('Quote') } - - ); - } - - // Bridges can provide a 'external_url' to link back to the source. - if ( - typeof(mxEvent.event.content.external_url) === "string" && - isUrlPermitted(mxEvent.event.content.external_url) - ) { - externalURLButton = ( - - { _t('Source URL') } - - ); - } - - if (this.props.collapseReplyThread) { - collapseReplyThread = ( - - { _t('Collapse Reply Thread') } - - ); - } - - let reportEventButton; - if (mxEvent.getSender() !== me) { - reportEventButton = ( - - { _t('Report Content') } - - ); - } - - return ( -
- { resendReactionsButton } - { redactButton } - { forwardButton } - { pinButton } - { viewSourceButton } - { unhidePreviewButton } - { permalinkButton } - { quoteButton } - { externalURLButton } - { collapseReplyThread } - { reportEventButton } -
- ); - } -} diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx new file mode 100644 index 0000000000..22dd3ac438 --- /dev/null +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -0,0 +1,418 @@ +/* +Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2015 - 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 from 'react'; +import { EventStatus, MatrixEvent } from 'matrix-js-sdk/src/models/event'; +import { EventType, RelationType } from "matrix-js-sdk/src/@types/event"; + +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import dis from '../../../dispatcher/dispatcher'; +import { _t } from '../../../languageHandler'; +import Modal from '../../../Modal'; +import Resend from '../../../Resend'; +import SettingsStore from '../../../settings/SettingsStore'; +import { isUrlPermitted } from '../../../HtmlUtils'; +import { isContentActionable } from '../../../utils/EventUtils'; +import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from './IconizedContextMenu'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { ReadPinsEventId } from "../right_panel/PinnedMessagesCard"; +import ForwardDialog from "../dialogs/ForwardDialog"; +import { Action } from "../../../dispatcher/actions"; +import ReportEventDialog from '../dialogs/ReportEventDialog'; +import ViewSource from '../../structures/ViewSource'; +import { createRedactEventDialog } from '../dialogs/ConfirmRedactDialog'; +import ShareDialog from '../dialogs/ShareDialog'; +import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; +import { IPosition, ChevronFace } from '../../structures/ContextMenu'; + +export function canCancel(eventStatus: EventStatus): boolean { + return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; +} + +export interface IEventTileOps { + isWidgetHidden(): boolean; + unhideWidget(): void; +} + +export interface IOperableEventTile { + getEventTileOps(): IEventTileOps; +} + +interface IProps extends IPosition { + chevronFace: ChevronFace; + /* the MatrixEvent associated with the context menu */ + mxEvent: MatrixEvent; + /* an optional EventTileOps implementation that can be used to unhide preview widgets */ + eventTileOps?: IEventTileOps; + permalinkCreator?: RoomPermalinkCreator; + /* an optional function to be called when the user clicks collapse thread, if not provided hide button */ + collapseReplyThread?(): void; + /* callback called when the menu is dismissed */ + onFinished(): void; + /* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */ + onCloseDialog?(): void; +} + +interface IState { + canRedact: boolean; + canPin: boolean; +} + +@replaceableComponent("views.context_menus.MessageContextMenu") +export default class MessageContextMenu extends React.Component { + state = { + canRedact: false, + canPin: false, + }; + + componentDidMount() { + MatrixClientPeg.get().on('RoomMember.powerLevel', this.checkPermissions); + this.checkPermissions(); + } + + componentWillUnmount() { + const cli = MatrixClientPeg.get(); + if (cli) { + cli.removeListener('RoomMember.powerLevel', this.checkPermissions); + } + } + + private checkPermissions = (): void => { + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(this.props.mxEvent.getRoomId()); + + // We explicitly decline to show the redact option on ACL events as it has a potential + // to obliterate the room - https://github.com/matrix-org/synapse/issues/4042 + // Similarly for encryption events, since redacting them "breaks everything" + const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId) + && this.props.mxEvent.getType() !== EventType.RoomServerAcl + && this.props.mxEvent.getType() !== EventType.RoomEncryption; + let canPin = room.currentState.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli); + + // HACK: Intentionally say we can't pin if the user doesn't want to use the functionality + if (!SettingsStore.getValue("feature_pinning")) canPin = false; + + this.setState({ canRedact, canPin }); + }; + + private isPinned(): boolean { + const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); + const pinnedEvent = room.currentState.getStateEvents(EventType.RoomPinnedEvents, ''); + if (!pinnedEvent) return false; + const content = pinnedEvent.getContent(); + return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId()); + } + + private onResendReactionsClick = (): void => { + for (const reaction of this.getUnsentReactions()) { + Resend.resend(reaction); + } + this.closeMenu(); + }; + + private onReportEventClick = (): void => { + Modal.createTrackedDialog('Report Event', '', ReportEventDialog, { + mxEvent: this.props.mxEvent, + }, 'mx_Dialog_reportEvent'); + this.closeMenu(); + }; + + private onViewSourceClick = (): void => { + Modal.createTrackedDialog('View Event Source', '', ViewSource, { + mxEvent: this.props.mxEvent, + }, 'mx_Dialog_viewsource'); + this.closeMenu(); + }; + + private onRedactClick = (): void => { + const { mxEvent, onCloseDialog } = this.props; + createRedactEventDialog({ + mxEvent, + onCloseDialog, + }); + this.closeMenu(); + }; + + private onForwardClick = (): void => { + Modal.createTrackedDialog('Forward Message', '', ForwardDialog, { + matrixClient: MatrixClientPeg.get(), + event: this.props.mxEvent, + permalinkCreator: this.props.permalinkCreator, + }); + this.closeMenu(); + }; + + private onPinClick = (): void => { + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(this.props.mxEvent.getRoomId()); + const eventId = this.props.mxEvent.getId(); + + const pinnedIds = room?.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.getContent().pinned || []; + if (pinnedIds.includes(eventId)) { + pinnedIds.splice(pinnedIds.indexOf(eventId), 1); + } else { + pinnedIds.push(eventId); + cli.setRoomAccountData(room.roomId, ReadPinsEventId, { + event_ids: [ + ...(room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids || []), + eventId, + ], + }); + } + cli.sendStateEvent(this.props.mxEvent.getRoomId(), EventType.RoomPinnedEvents, { pinned: pinnedIds }, ""); + this.closeMenu(); + }; + + private closeMenu = (): void => { + this.props.onFinished(); + }; + + private onUnhidePreviewClick = (): void => { + this.props.eventTileOps?.unhideWidget(); + this.closeMenu(); + }; + + private onQuoteClick = (): void => { + dis.dispatch({ + action: Action.ComposerInsert, + event: this.props.mxEvent, + }); + this.closeMenu(); + }; + + private onPermalinkClick = (e: React.MouseEvent): void => { + e.preventDefault(); + Modal.createTrackedDialog('share room message dialog', '', ShareDialog, { + target: this.props.mxEvent, + permalinkCreator: this.props.permalinkCreator, + }); + this.closeMenu(); + }; + + private onCollapseReplyThreadClick = (): void => { + this.props.collapseReplyThread(); + this.closeMenu(); + }; + + private getReactions(filter: (e: MatrixEvent) => boolean): MatrixEvent[] { + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(this.props.mxEvent.getRoomId()); + const eventId = this.props.mxEvent.getId(); + return room.getPendingEvents().filter(e => { + const relation = e.getRelation(); + return relation?.rel_type === RelationType.Annotation && relation.event_id === eventId && filter(e); + }); + } + + private getPendingReactions(): MatrixEvent[] { + return this.getReactions(e => canCancel(e.status)); + } + + private getUnsentReactions(): MatrixEvent[] { + return this.getReactions(e => e.status === EventStatus.NOT_SENT); + } + + render() { + const cli = MatrixClientPeg.get(); + const me = cli.getUserId(); + const mxEvent = this.props.mxEvent; + const eventStatus = mxEvent.status; + const unsentReactionsCount = this.getUnsentReactions().length; + + let resendReactionsButton: JSX.Element; + let redactButton: JSX.Element; + let forwardButton: JSX.Element; + let pinButton: JSX.Element; + let unhidePreviewButton: JSX.Element; + let externalURLButton: JSX.Element; + let quoteButton: JSX.Element; + let collapseReplyThread: JSX.Element; + let redactItemList: JSX.Element; + + // status is SENT before remote-echo, null after + const isSent = !eventStatus || eventStatus === EventStatus.SENT; + if (!mxEvent.isRedacted()) { + if (unsentReactionsCount !== 0) { + resendReactionsButton = ( + + ); + } + } + + if (isSent && this.state.canRedact) { + redactButton = ( + + ); + } + + if (isContentActionable(mxEvent)) { + forwardButton = ( + + ); + + if (this.state.canPin) { + pinButton = ( + + ); + } + } + + const viewSourceButton = ( + + ); + + if (this.props.eventTileOps) { + if (this.props.eventTileOps.isWidgetHidden()) { + unhidePreviewButton = ( + + ); + } + } + + let permalink; + if (this.props.permalinkCreator) { + permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()); + } + const permalinkButton = ( + + ); + + if (this.props.eventTileOps) { // this event is rendered using TextualBody + quoteButton = ( + + ); + } + + // Bridges can provide a 'external_url' to link back to the source. + if (typeof (mxEvent.getContent().external_url) === "string" && + isUrlPermitted(mxEvent.getContent().external_url) + ) { + externalURLButton = ( + + ); + } + + if (this.props.collapseReplyThread) { + collapseReplyThread = ( + + ); + } + + let reportEventButton: JSX.Element; + if (mxEvent.getSender() !== me) { + reportEventButton = ( + + ); + } + + const commonItemsList = ( + + { quoteButton } + { forwardButton } + { pinButton } + { permalinkButton } + { reportEventButton } + { externalURLButton } + { unhidePreviewButton } + { viewSourceButton } + { resendReactionsButton } + { collapseReplyThread } + + ); + + if (redactButton) { + redactItemList = ( + + { redactButton } + + ); + } + + return ( + + { commonItemsList } + { redactItemList } + + ); + } +} diff --git a/src/components/views/context_menus/SpaceContextMenu.tsx b/src/components/views/context_menus/SpaceContextMenu.tsx new file mode 100644 index 0000000000..28c35eef8f --- /dev/null +++ b/src/components/views/context_menus/SpaceContextMenu.tsx @@ -0,0 +1,216 @@ +/* +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, { useContext } from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { EventType } from "matrix-js-sdk/src/@types/event"; + +import { + IProps as IContextMenuProps, +} from "../../structures/ContextMenu"; +import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu"; +import { _t } from "../../../languageHandler"; +import { + leaveSpace, + shouldShowSpaceSettings, + showAddExistingRooms, + showCreateNewRoom, + showCreateNewSubspace, + showSpaceInvite, + showSpaceSettings, +} from "../../../utils/space"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { ButtonEvent } from "../elements/AccessibleButton"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import RoomViewStore from "../../../stores/RoomViewStore"; +import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; +import { Action } from "../../../dispatcher/actions"; +import { RightPanelPhases } from "../../../stores/RightPanelStorePhases"; +import { BetaPill } from "../beta/BetaCard"; + +interface IProps extends IContextMenuProps { + space: Room; +} + +const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => { + const cli = useContext(MatrixClientContext); + const userId = cli.getUserId(); + + let inviteOption; + if (space.getJoinRule() === "public" || space.canInvite(userId)) { + const onInviteClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showSpaceInvite(space); + onFinished(); + }; + + inviteOption = ( + + ); + } + + let settingsOption; + let leaveSection; + if (shouldShowSpaceSettings(space)) { + const onSettingsClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showSpaceSettings(space); + onFinished(); + }; + + settingsOption = ( + + ); + } else { + const onLeaveClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + leaveSpace(space); + onFinished(); + }; + + leaveSection = + + ; + } + + const canAddRooms = space.currentState.maySendStateEvent(EventType.SpaceChild, userId); + + let newRoomSection; + if (space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) { + const onNewRoomClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showCreateNewRoom(space); + onFinished(); + }; + + const onAddExistingRoomClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showAddExistingRooms(space); + onFinished(); + }; + + const onNewSubspaceClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showCreateNewSubspace(space); + onFinished(); + }; + + newRoomSection = + + + + + + ; + } + + const onMembersClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + if (!RoomViewStore.getRoomId()) { + defaultDispatcher.dispatch({ + action: "view_room", + room_id: space.roomId, + }, true); + } + + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.SpaceMemberList, + refireParams: { space }, + }); + onFinished(); + }; + + const onExploreRoomsClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + defaultDispatcher.dispatch({ + action: "view_room", + room_id: space.roomId, + }); + onFinished(); + }; + + return +
+ { space.name } +
+ + { inviteOption } + + { settingsOption } + + + { newRoomSection } + { leaveSection } +
; +}; + +export default SpaceContextMenu; + diff --git a/src/components/views/context_menus/StatusMessageContextMenu.js b/src/components/views/context_menus/StatusMessageContextMenu.tsx similarity index 53% rename from src/components/views/context_menus/StatusMessageContextMenu.js rename to src/components/views/context_menus/StatusMessageContextMenu.tsx index 41f0e0ba61..954dc3f5c0 100644 --- a/src/components/views/context_menus/StatusMessageContextMenu.js +++ b/src/components/views/context_menus/StatusMessageContextMenu.tsx @@ -14,53 +14,59 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { ChangeEvent } from 'react'; import { _t } from '../../../languageHandler'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import * as sdk from '../../../index'; -import AccessibleButton from '../elements/AccessibleButton'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { User } from "matrix-js-sdk/src/models/user"; +import Spinner from "../elements/Spinner"; + +interface IProps { + // js-sdk User object. Not required because it might not exist. + user?: User; +} + +interface IState { + message: string; + waiting: boolean; +} @replaceableComponent("views.context_menus.StatusMessageContextMenu") -export default class StatusMessageContextMenu extends React.Component { - static propTypes = { - // js-sdk User object. Not required because it might not exist. - user: PropTypes.object, - }; - - constructor(props) { +export default class StatusMessageContextMenu extends React.Component { + constructor(props: IProps) { super(props); this.state = { message: this.comittedStatusMessage, + waiting: false, }; } - componentDidMount() { + public componentDidMount(): void { const { user } = this.props; if (!user) { return; } - user.on("User._unstable_statusMessage", this._onStatusMessageCommitted); + user.on("User._unstable_statusMessage", this.onStatusMessageCommitted); } - componentWillUnmount() { + public componentWillUnmount(): void { const { user } = this.props; if (!user) { return; } user.removeListener( "User._unstable_statusMessage", - this._onStatusMessageCommitted, + this.onStatusMessageCommitted, ); } - get comittedStatusMessage() { - return this.props.user ? this.props.user._unstable_statusMessage : ""; + get comittedStatusMessage(): string { + return this.props.user ? this.props.user.unstable_statusMessage : ""; } - _onStatusMessageCommitted = () => { + private onStatusMessageCommitted = (): void => { // The `User` object has observed a status message change. this.setState({ message: this.comittedStatusMessage, @@ -68,14 +74,14 @@ export default class StatusMessageContextMenu extends React.Component { }); }; - _onClearClick = (e) => { + private onClearClick = (): void=> { MatrixClientPeg.get()._unstable_setStatusMessage(""); this.setState({ waiting: true, }); }; - _onSubmit = (e) => { + private onSubmit = (e: ButtonEvent): void => { e.preventDefault(); MatrixClientPeg.get()._unstable_setStatusMessage(this.state.message); this.setState({ @@ -83,55 +89,62 @@ export default class StatusMessageContextMenu extends React.Component { }); }; - _onStatusChange = (e) => { + private onStatusChange = (e: ChangeEvent): void => { // The input field's value was changed. this.setState({ - message: e.target.value, + message: (e.target as HTMLInputElement).value, }); }; - render() { - const Spinner = sdk.getComponent('views.elements.Spinner'); - + public render(): JSX.Element { let actionButton; if (this.comittedStatusMessage) { if (this.state.message === this.comittedStatusMessage) { actionButton = - {_t("Clear status")} + { _t("Clear status") } ; } else { actionButton = - {_t("Update status")} + { _t("Update status") } ; } } else { - actionButton = - {_t("Set status")} + { _t("Set status") } ; } let spinner = null; if (this.state.waiting) { - spinner = ; + spinner = ; } - const form = -
- {actionButton} - {spinner} + { actionButton } + { spinner }
; diff --git a/src/components/views/context_menus/TagTileContextMenu.js b/src/components/views/context_menus/TagTileContextMenu.js index 8dea62690c..0c3c48a07f 100644 --- a/src/components/views/context_menus/TagTileContextMenu.js +++ b/src/components/views/context_menus/TagTileContextMenu.js @@ -20,49 +20,92 @@ import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import dis from '../../../dispatcher/dispatcher'; import TagOrderActions from '../../../actions/TagOrderActions'; -import {MenuItem} from "../../structures/ContextMenu"; +import { MenuItem } from "../../structures/ContextMenu"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore"; +import { createSpaceFromCommunity } from "../../../utils/space"; +import GroupStore from "../../../stores/GroupStore"; @replaceableComponent("views.context_menus.TagTileContextMenu") export default class TagTileContextMenu extends React.Component { static propTypes = { tag: PropTypes.string.isRequired, + index: PropTypes.number.isRequired, /* callback called when the menu is dismissed */ onFinished: PropTypes.func.isRequired, }; static contextType = MatrixClientContext; - constructor() { - super(); - - this._onViewCommunityClick = this._onViewCommunityClick.bind(this); - this._onRemoveClick = this._onRemoveClick.bind(this); - } - - _onViewCommunityClick() { + _onViewCommunityClick = () => { dis.dispatch({ action: 'view_group', group_id: this.props.tag, }); this.props.onFinished(); - } + }; - _onRemoveClick() { + _onRemoveClick = () => { dis.dispatch(TagOrderActions.removeTag(this.context, this.props.tag)); this.props.onFinished(); - } + }; + + _onCreateSpaceClick = () => { + createSpaceFromCommunity(this.context, this.props.tag); + this.props.onFinished(); + }; + + _onMoveUp = () => { + dis.dispatch(TagOrderActions.moveTag(this.context, this.props.tag, this.props.index - 1)); + this.props.onFinished(); + }; + + _onMoveDown = () => { + dis.dispatch(TagOrderActions.moveTag(this.context, this.props.tag, this.props.index + 1)); + this.props.onFinished(); + }; render() { + let moveUp; + let moveDown; + if (this.props.index > 0) { + moveUp = ( + + { _t("Move up") } + + ); + } + if (this.props.index < (GroupFilterOrderStore.getOrderedTags() || []).length - 1) { + moveDown = ( + + { _t("Move down") } + + ); + } + + let createSpaceOption; + if (GroupStore.isUserPrivileged(this.props.tag)) { + createSpaceOption = <> +
+ + { _t("Create Space") } + + ; + } + return
{ _t('View Community') } + { (moveUp || moveDown) ?
: null } + { moveUp } + { moveDown }
- { _t('Hide') } + { _t("Unpin") } + { createSpaceOption }
; } } diff --git a/src/components/views/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx index 623fe04f2f..26d7b640a4 100644 --- a/src/components/views/context_menus/WidgetContextMenu.tsx +++ b/src/components/views/context_menus/WidgetContextMenu.tsx @@ -14,22 +14,22 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useContext} from "react"; -import {MatrixCapabilities} from "matrix-widget-api"; +import React, { useContext } from "react"; +import { MatrixCapabilities } from "matrix-widget-api"; -import IconizedContextMenu, {IconizedContextMenuOption, IconizedContextMenuOptionList} from "./IconizedContextMenu"; -import {ChevronFace} from "../../structures/ContextMenu"; -import {_t} from "../../../languageHandler"; -import {IApp} from "../../../stores/WidgetStore"; +import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu"; +import { ChevronFace } from "../../structures/ContextMenu"; +import { _t } from "../../../languageHandler"; +import { IApp } from "../../../stores/WidgetStore"; import WidgetUtils from "../../../utils/WidgetUtils"; -import {WidgetMessagingStore} from "../../../stores/widgets/WidgetMessagingStore"; +import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore"; import RoomContext from "../../../contexts/RoomContext"; import dis from "../../../dispatcher/dispatcher"; import SettingsStore from "../../../settings/SettingsStore"; import Modal from "../../../Modal"; import QuestionDialog from "../dialogs/QuestionDialog"; import ErrorDialog from "../dialogs/ErrorDialog"; -import {WidgetType} from "../../../widgets/WidgetType"; +import { WidgetType } from "../../../widgets/WidgetType"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import { getConfigLivestreamUrl, startJitsiAudioLivestream } from "../../../Livestream"; @@ -40,6 +40,8 @@ interface IProps extends React.ComponentProps { showUnpin?: boolean; // override delete handler onDeleteClick?(): void; + // override edit handler + onEditClick?(): void; } const WidgetContextMenu: React.FC = ({ @@ -47,11 +49,12 @@ const WidgetContextMenu: React.FC = ({ app, userWidget, onDeleteClick, + onEditClick, showUnpin, ...props }) => { const cli = useContext(MatrixClientContext); - const {room, roomId} = useContext(RoomContext); + const { room, roomId } = useContext(RoomContext); const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id); const canModify = userWidget || WidgetUtils.canUserModifyWidgets(roomId); @@ -73,7 +76,8 @@ const WidgetContextMenu: React.FC = ({ onFinished(); }; streamAudioStreamButton = ; } @@ -89,12 +93,16 @@ const WidgetContextMenu: React.FC = ({ let editButton; if (canModify && WidgetUtils.isManagedByManager(app)) { - const onEditClick = () => { - WidgetUtils.editWidget(room, app); + const _onEditClick = () => { + if (onEditClick) { + onEditClick(); + } else { + WidgetUtils.editWidget(room, app); + } onFinished(); }; - editButton = ; + editButton = ; } let snapshotButton; @@ -116,24 +124,29 @@ const WidgetContextMenu: React.FC = ({ let deleteButton; if (onDeleteClick || canModify) { - const onDeleteClickDefault = () => { - // Show delete confirmation dialog - Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, { - title: _t("Delete Widget"), - description: _t( - "Deleting a widget removes it for all users in this room." + - " Are you sure you want to delete this widget?"), - button: _t("Delete widget"), - onFinished: (confirmed) => { - if (!confirmed) return; - WidgetUtils.setRoomWidget(roomId, app.id); - }, - }); + const _onDeleteClick = () => { + if (onDeleteClick) { + onDeleteClick(); + } else { + // Show delete confirmation dialog + Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, { + title: _t("Delete Widget"), + description: _t( + "Deleting a widget removes it for all users in this room." + + " Are you sure you want to delete this widget?"), + button: _t("Delete widget"), + onFinished: (confirmed) => { + if (!confirmed) return; + WidgetUtils.setRoomWidget(roomId, app.id); + }, + }); + } + onFinished(); }; deleteButton = ; } diff --git a/src/components/views/dialogs/AddExistingSubspaceDialog.tsx b/src/components/views/dialogs/AddExistingSubspaceDialog.tsx new file mode 100644 index 0000000000..7fef2c2d9d --- /dev/null +++ b/src/components/views/dialogs/AddExistingSubspaceDialog.tsx @@ -0,0 +1,67 @@ +/* +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, { useState } from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; + +import { _t } from '../../../languageHandler'; +import BaseDialog from "./BaseDialog"; +import AccessibleButton from "../elements/AccessibleButton"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { AddExistingToSpace, defaultSpacesRenderer, SubspaceSelector } from "./AddExistingToSpaceDialog"; + +interface IProps { + space: Room; + onCreateSubspaceClick(): void; + onFinished(added?: boolean): void; +} + +const AddExistingSubspaceDialog: React.FC = ({ space, onCreateSubspaceClick, onFinished }) => { + const [selectedSpace, setSelectedSpace] = useState(space); + + return + )} + className="mx_AddExistingToSpaceDialog" + contentId="mx_AddExistingToSpace" + onFinished={onFinished} + fixedWidth={false} + > + + +
{ _t("Want to add a new space instead?") }
+ + { _t("Create a new space") } + + } + filterPlaceholder={_t("Search for spaces")} + spacesRenderer={defaultSpacesRenderer} + /> +
+
; +}; + +export default AddExistingSubspaceDialog; + diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index 822ffc2827..01a767bf14 100644 --- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -14,39 +14,41 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {ReactNode, useContext, useMemo, useState} from "react"; +import React, { ReactNode, useContext, useMemo, useState } from "react"; import classNames from "classnames"; -import {Room} from "matrix-js-sdk/src/models/room"; -import {MatrixClient} from "matrix-js-sdk/src/client"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { sleep } from "matrix-js-sdk/src/utils"; +import { EventType } from "matrix-js-sdk/src/@types/event"; -import {_t} from '../../../languageHandler'; -import {IDialogProps} from "./IDialogProps"; +import { _t } from '../../../languageHandler'; import BaseDialog from "./BaseDialog"; import Dropdown from "../elements/Dropdown"; import SearchBox from "../../structures/SearchBox"; import SpaceStore from "../../../stores/SpaceStore"; import RoomAvatar from "../avatars/RoomAvatar"; -import {getDisplayAliasForRoom} from "../../../Rooms"; +import { getDisplayAliasForRoom } from "../../../Rooms"; import AccessibleButton from "../elements/AccessibleButton"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; -import {sleep} from "../../../utils/promise"; import DMRoomMap from "../../../utils/DMRoomMap"; -import {calculateRoomVia} from "../../../utils/permalinks/Permalinks"; +import { calculateRoomVia } from "../../../utils/permalinks/Permalinks"; import StyledCheckbox from "../elements/StyledCheckbox"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {sortRooms} from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; +import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; import ProgressBar from "../elements/ProgressBar"; -import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; import QueryMatcher from "../../../autocomplete/QueryMatcher"; +import TruncatedList from "../elements/TruncatedList"; +import EntityTile from "../rooms/EntityTile"; +import BaseAvatar from "../avatars/BaseAvatar"; -interface IProps extends IDialogProps { - matrixClient: MatrixClient; +interface IProps { space: Room; - onCreateRoomClick(cli: MatrixClient, space: Room): void; + onCreateRoomClick(): void; + onAddSubspaceClick(): void; + onFinished(added?: boolean): void; } -const Entry = ({ room, checked, onChange }) => { +export const Entry = ({ room, checked, onChange }) => { return
; } else { - identityServer =
{_t( + identityServer =
{ _t( "Use an identity server to invite by email. " + "Manage in Settings.", {}, { - settings: sub => {sub}, + settings: sub => { sub }, }, - )}
; + ) }
; } } return ( - - {inputLabel} + + { inputLabel }
{ query }
{ error } diff --git a/src/components/views/dialogs/AskInviteAnywayDialog.js b/src/components/views/dialogs/AskInviteAnywayDialog.tsx similarity index 62% rename from src/components/views/dialogs/AskInviteAnywayDialog.js rename to src/components/views/dialogs/AskInviteAnywayDialog.tsx index e6cd45ba6b..3ae82f1026 100644 --- a/src/components/views/dialogs/AskInviteAnywayDialog.js +++ b/src/components/views/dialogs/AskInviteAnywayDialog.tsx @@ -15,65 +15,66 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; -import {SettingLevel} from "../../../settings/SettingLevel"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { SettingLevel } from "../../../settings/SettingLevel"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseDialog from "./BaseDialog"; + +interface IProps { + unknownProfileUsers: Array<{ + userId: string; + errorText: string; + }>; + onInviteAnyways: () => void; + onGiveUp: () => void; + onFinished: (success: boolean) => void; +} @replaceableComponent("views.dialogs.AskInviteAnywayDialog") -export default class AskInviteAnywayDialog extends React.Component { - static propTypes = { - unknownProfileUsers: PropTypes.array.isRequired, // [ {userId, errorText}... ] - onInviteAnyways: PropTypes.func.isRequired, - onGiveUp: PropTypes.func.isRequired, - onFinished: PropTypes.func.isRequired, - }; - - _onInviteClicked = () => { +export default class AskInviteAnywayDialog extends React.Component { + private onInviteClicked = (): void => { this.props.onInviteAnyways(); this.props.onFinished(true); }; - _onInviteNeverWarnClicked = () => { + private onInviteNeverWarnClicked = (): void => { SettingsStore.setValue("promptBeforeInviteUnknownUsers", null, SettingLevel.ACCOUNT, false); this.props.onInviteAnyways(); this.props.onFinished(true); }; - _onGiveUpClicked = () => { + private onGiveUpClicked = (): void => { this.props.onGiveUp(); this.props.onFinished(false); }; - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - + public render() { const errorList = this.props.unknownProfileUsers - .map(address =>
  • {address.userId}: {address.errorText}
  • ); + .map(address =>
  • { address.userId }: { address.errorText }
  • ); return (
    -

    {_t("Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?")}

    +

    { _t("Unable to find profiles for the Matrix IDs listed below - " + + "would you like to invite them anyway?") }

      { errorList }
    - - -
    diff --git a/src/components/views/dialogs/BaseDialog.js b/src/components/views/dialogs/BaseDialog.tsx similarity index 57% rename from src/components/views/dialogs/BaseDialog.js rename to src/components/views/dialogs/BaseDialog.tsx index 0858e53e50..0af494f53e 100644 --- a/src/components/views/dialogs/BaseDialog.js +++ b/src/components/views/dialogs/BaseDialog.tsx @@ -18,15 +18,54 @@ limitations under the License. import React from 'react'; import FocusLock from 'react-focus-lock'; -import PropTypes from 'prop-types'; import classNames from 'classnames'; import { Key } from '../../../Keyboard'; -import AccessibleButton from '../elements/AccessibleButton'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { _t } from "../../../languageHandler"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { IDialogProps } from "./IDialogProps"; + +interface IProps extends IDialogProps { + // Whether the dialog should have a 'close' button that will + // cause the dialog to be cancelled. This should only be set + // to false if there is nothing the app can sensibly do if the + // dialog is cancelled, eg. "We can't restore your session and + // the app cannot work". Default: true. + hasCancel?: boolean; + + // called when a key is pressed + onKeyDown?: (e: KeyboardEvent | React.KeyboardEvent) => void; + + // CSS class to apply to dialog div + className?: string; + + // if true, dialog container is 60% of the viewport width. Otherwise, + // the container will have no fixed size, allowing its contents to + // determine its size. Default: true. + fixedWidth?: boolean; + + // Title for the dialog. + title?: JSX.Element | string; + + // Path to an icon to put in the header + headerImage?: string; + + // children should be the content of the dialog + children?: React.ReactNode; + + // Id of content element + // If provided, this is used to add a aria-describedby attribute + contentId?: string; + + // optional additional class for the title element (basically anything that can be passed to classnames) + titleClass?: string | string[]; + + headerButton?: JSX.Element; +} /* * Basic container for modal dialogs. @@ -35,54 +74,10 @@ import {replaceableComponent} from "../../../utils/replaceableComponent"; * dialog on escape. */ @replaceableComponent("views.dialogs.BaseDialog") -export default class BaseDialog extends React.Component { - static propTypes = { - // onFinished callback to call when Escape is pressed - // Take a boolean which is true if the dialog was dismissed - // with a positive / confirm action or false if it was - // cancelled (BaseDialog itself only calls this with false). - onFinished: PropTypes.func.isRequired, +export default class BaseDialog extends React.Component { + private matrixClient: MatrixClient; - // Whether the dialog should have a 'close' button that will - // cause the dialog to be cancelled. This should only be set - // to false if there is nothing the app can sensibly do if the - // dialog is cancelled, eg. "We can't restore your session and - // the app cannot work". Default: true. - hasCancel: PropTypes.bool, - - // called when a key is pressed - onKeyDown: PropTypes.func, - - // CSS class to apply to dialog div - className: PropTypes.string, - - // if true, dialog container is 60% of the viewport width. Otherwise, - // the container will have no fixed size, allowing its contents to - // determine its size. Default: true. - fixedWidth: PropTypes.bool, - - // Title for the dialog. - title: PropTypes.node.isRequired, - - // Path to an icon to put in the header - headerImage: PropTypes.string, - - // children should be the content of the dialog - children: PropTypes.node, - - // Id of content element - // If provided, this is used to add a aria-describedby attribute - contentId: PropTypes.string, - - // optional additional class for the title element (basically anything that can be passed to classnames) - titleClass: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.object, - PropTypes.arrayOf(PropTypes.string), - ]), - }; - - static defaultProps = { + public static defaultProps = { hasCancel: true, fixedWidth: true, }; @@ -90,10 +85,10 @@ export default class BaseDialog extends React.Component { constructor(props) { super(props); - this._matrixClient = MatrixClientPeg.get(); + this.matrixClient = MatrixClientPeg.get(); } - _onKeyDown = (e) => { + private onKeyDown = (e: KeyboardEvent | React.KeyboardEvent): void => { if (this.props.onKeyDown) { this.props.onKeyDown(e); } @@ -104,31 +99,29 @@ export default class BaseDialog extends React.Component { } }; - _onCancelClick = (e) => { + private onCancelClick = (e: ButtonEvent): void => { this.props.onFinished(false); }; - render() { + public render(): JSX.Element { let cancelButton; if (this.props.hasCancel) { cancelButton = ( - + ); } let headerImage; if (this.props.headerImage) { - headerImage = ; + headerImage = ; } return ( - +
    - {headerImage} + { headerImage } { this.props.title }
    { this.props.headerButton } diff --git a/src/components/views/dialogs/BetaFeedbackDialog.tsx b/src/components/views/dialogs/BetaFeedbackDialog.tsx index 1ae50dd66f..c5fba52b51 100644 --- a/src/components/views/dialogs/BetaFeedbackDialog.tsx +++ b/src/components/views/dialogs/BetaFeedbackDialog.tsx @@ -14,93 +14,48 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useState} from "react"; +import React from "react"; -import QuestionDialog from './QuestionDialog'; import { _t } from '../../../languageHandler'; -import Field from "../elements/Field"; -import SdkConfig from "../../../SdkConfig"; -import {IDialogProps} from "./IDialogProps"; +import { IDialogProps } from "./IDialogProps"; import SettingsStore from "../../../settings/SettingsStore"; -import {submitFeedback} from "../../../rageshake/submit-rageshake"; -import StyledCheckbox from "../elements/StyledCheckbox"; -import Modal from "../../../Modal"; -import InfoDialog from "./InfoDialog"; import AccessibleButton from "../elements/AccessibleButton"; import defaultDispatcher from "../../../dispatcher/dispatcher"; -import {Action} from "../../../dispatcher/actions"; -import {USER_LABS_TAB} from "./UserSettingsDialog"; +import { Action } from "../../../dispatcher/actions"; +import { UserTab } from "./UserSettingsDialog"; +import GenericFeatureFeedbackDialog from "./GenericFeatureFeedbackDialog"; + +// XXX: Keep this around for re-use in future Betas interface IProps extends IDialogProps { featureId: string; } -const BetaFeedbackDialog: React.FC = ({featureId, onFinished}) => { +const BetaFeedbackDialog: React.FC = ({ featureId, onFinished }) => { const info = SettingsStore.getBetaInfo(featureId); - const [comment, setComment] = useState(""); - const [canContact, setCanContact] = useState(false); - - const sendFeedback = async (ok: boolean) => { - if (!ok) return onFinished(false); - - submitFeedback(SdkConfig.get().bug_report_endpoint_url, info.feedbackLabel, comment, canContact); - onFinished(true); - - Modal.createTrackedDialog("Beta Dialog Sent", featureId, InfoDialog, { - title: _t("Beta feedback"), - description: _t("Thank you for your feedback, we really appreciate it."), - button: _t("Done"), - hasCloseButton: false, - fixedWidth: false, - }); - }; - - return ( -
    - { _t(info.feedbackSubheading) } -   - { _t("Your platform and username will be noted to help us use your feedback as much as we can.")} - - { - onFinished(false); - defaultDispatcher.dispatch({ - action: Action.ViewUserSettings, - initialTabId: USER_LABS_TAB, - }); - }}> - { _t("To leave the beta, visit your settings.") } - -
    - - { - setComment(ev.target.value); - }} - autoFocus={true} - /> - - setCanContact((e.target as HTMLInputElement).checked)} - > - { _t("You may contact me if you have any follow up questions") } - - } - button={_t("Send feedback")} - buttonDisabled={!comment} - onFinished={sendFeedback} - />); + subheading={_t(info.feedbackSubheading)} + onFinished={onFinished} + rageshakeLabel={info.feedbackLabel} + rageshakeData={Object.fromEntries((SettingsStore.getBetaInfo(featureId)?.extraSettings || []).map(k => { + return SettingsStore.getValue(k); + }))} + > + { + onFinished(false); + defaultDispatcher.dispatch({ + action: Action.ViewUserSettings, + initialTabId: UserTab.Labs, + }); + }} + > + { _t("To leave the beta, visit your settings.") } + + ; }; export default BetaFeedbackDialog; diff --git a/src/components/views/dialogs/BugReportDialog.js b/src/components/views/dialogs/BugReportDialog.tsx similarity index 67% rename from src/components/views/dialogs/BugReportDialog.js rename to src/components/views/dialogs/BugReportDialog.tsx index cbe0130649..38566cdf04 100644 --- a/src/components/views/dialogs/BugReportDialog.js +++ b/src/components/views/dialogs/BugReportDialog.tsx @@ -18,17 +18,41 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; -import sendBugReport, {downloadBugReport} from '../../../rageshake/submit-rageshake'; +import sendBugReport, { downloadBugReport } from '../../../rageshake/submit-rageshake'; import AccessibleButton from "../elements/AccessibleButton"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import QuestionDialog from "./QuestionDialog"; +import BaseDialog from "./BaseDialog"; +import Field from '../elements/Field'; +import Spinner from "../elements/Spinner"; +import DialogButtons from "../elements/DialogButtons"; +import { sendSentryReport } from "../../../sentry"; + +interface IProps { + onFinished: (success: boolean) => void; + initialText?: string; + label?: string; + error?: Error; +} + +interface IState { + sendLogs: boolean; + busy: boolean; + err: string; + issueUrl: string; + text: string; + progress: string; + downloadBusy: boolean; + downloadProgress: string; +} @replaceableComponent("views.dialogs.BugReportDialog") -export default class BugReportDialog extends React.Component { +export default class BugReportDialog extends React.Component { + private unmounted: boolean; + constructor(props) { super(props); this.state = { @@ -41,25 +65,18 @@ export default class BugReportDialog extends React.Component { downloadBusy: false, downloadProgress: null, }; - this._unmounted = false; - this._onSubmit = this._onSubmit.bind(this); - this._onCancel = this._onCancel.bind(this); - this._onTextChange = this._onTextChange.bind(this); - this._onIssueUrlChange = this._onIssueUrlChange.bind(this); - this._onSendLogsChange = this._onSendLogsChange.bind(this); - this._sendProgressCallback = this._sendProgressCallback.bind(this); - this._downloadProgressCallback = this._downloadProgressCallback.bind(this); + this.unmounted = false; } - componentWillUnmount() { - this._unmounted = true; + public componentWillUnmount() { + this.unmounted = true; } - _onCancel(ev) { + private onCancel = (): void => { this.props.onFinished(false); - } + }; - _onSubmit(ev) { + private onSubmit = (): void => { if ((!this.state.text || !this.state.text.trim()) && (!this.state.issueUrl || !this.state.issueUrl.trim())) { this.setState({ err: _t("Please tell us what went wrong or, better, create a GitHub issue that describes the problem."), @@ -72,17 +89,16 @@ export default class BugReportDialog extends React.Component { (this.state.issueUrl.length > 0 ? this.state.issueUrl : 'No issue link given'); this.setState({ busy: true, progress: null, err: null }); - this._sendProgressCallback(_t("Preparing to send logs")); + this.sendProgressCallback(_t("Preparing to send logs")); sendBugReport(SdkConfig.get().bug_report_endpoint_url, { userText, sendLogs: true, - progressCallback: this._sendProgressCallback, + progressCallback: this.sendProgressCallback, label: this.props.label, }).then(() => { - if (!this._unmounted) { + if (!this.unmounted) { this.props.onFinished(false); - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); // N.B. first param is passed to piwik and so doesn't want i18n Modal.createTrackedDialog('Bug report sent', '', QuestionDialog, { title: _t('Logs sent'), @@ -91,7 +107,7 @@ export default class BugReportDialog extends React.Component { }); } }, (err) => { - if (!this._unmounted) { + if (!this.unmounted) { this.setState({ busy: false, progress: null, @@ -99,16 +115,18 @@ export default class BugReportDialog extends React.Component { }); } }); - } - _onDownload = async (ev) => { + sendSentryReport(this.state.text, this.state.issueUrl, this.props.error); + }; + + private onDownload = async (): Promise => { this.setState({ downloadBusy: true }); - this._downloadProgressCallback(_t("Preparing to download logs")); + this.downloadProgressCallback(_t("Preparing to download logs")); try { await downloadBugReport({ sendLogs: true, - progressCallback: this._downloadProgressCallback, + progressCallback: this.downloadProgressCallback, label: this.props.label, }); @@ -117,7 +135,7 @@ export default class BugReportDialog extends React.Component { downloadProgress: null, }); } catch (err) { - if (!this._unmounted) { + if (!this.unmounted) { this.setState({ downloadBusy: false, downloadProgress: _t("Failed to send logs: ") + `${err.message}`, @@ -126,42 +144,33 @@ export default class BugReportDialog extends React.Component { } }; - _onTextChange(ev) { - this.setState({ text: ev.target.value }); - } + private onTextChange = (ev: React.FormEvent): void => { + this.setState({ text: ev.currentTarget.value }); + }; - _onIssueUrlChange(ev) { - this.setState({ issueUrl: ev.target.value }); - } + private onIssueUrlChange = (ev: React.FormEvent): void => { + this.setState({ issueUrl: ev.currentTarget.value }); + }; - _onSendLogsChange(ev) { - this.setState({ sendLogs: ev.target.checked }); - } - - _sendProgressCallback(progress) { - if (this._unmounted) { + private sendProgressCallback = (progress: string): void => { + if (this.unmounted) { return; } - this.setState({progress: progress}); - } + this.setState({ progress }); + }; - _downloadProgressCallback(downloadProgress) { - if (this._unmounted) { + private downloadProgressCallback = (downloadProgress: string): void => { + if (this.unmounted) { return; } this.setState({ downloadProgress }); - } - - render() { - const Loader = sdk.getComponent("elements.Spinner"); - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - const Field = sdk.getComponent('elements.Field'); + }; + public render() { let error = null; if (this.state.err) { error =
    - {this.state.err} + { this.state.err }
    ; } @@ -169,8 +178,8 @@ export default class BugReportDialog extends React.Component { if (this.state.busy) { progress = (
    - - {this.state.progress} ... + + { this.state.progress } ...
    ); } @@ -183,7 +192,9 @@ export default class BugReportDialog extends React.Component { } return ( - @@ -193,8 +204,8 @@ export default class BugReportDialog extends React.Component { { _t( "Debug logs contain application usage data including your " + "username, the IDs or aliases of the rooms or groups you " + - "have visited and the usernames of other users. They do " + - "not contain messages.", + "have visited, which UI elements you last interacted with, " + + "and the usernames of other users. They do not contain messages.", ) }

    @@ -204,7 +215,7 @@ export default class BugReportDialog extends React.Component { { a: (sub) => { sub } , @@ -213,17 +224,17 @@ export default class BugReportDialog extends React.Component {

    - + { _t("Download logs") } - {this.state.downloadProgress && {this.state.downloadProgress} ...} + { this.state.downloadProgress && { this.state.downloadProgress } ... }
    @@ -232,7 +243,7 @@ export default class BugReportDialog extends React.Component { element="textarea" label={_t("Notes")} rows={5} - onChange={this._onTextChange} + onChange={this.onTextChange} value={this.state.text} placeholder={_t( "If there is additional context that would help in " + @@ -241,21 +252,16 @@ export default class BugReportDialog extends React.Component { "please include those things here.", )} /> - {progress} - {error} + { progress } + { error }
    ); } } - -BugReportDialog.propTypes = { - onFinished: PropTypes.func.isRequired, - initialText: PropTypes.string, -}; diff --git a/src/components/views/dialogs/ChangelogDialog.js b/src/components/views/dialogs/ChangelogDialog.tsx similarity index 75% rename from src/components/views/dialogs/ChangelogDialog.js rename to src/components/views/dialogs/ChangelogDialog.tsx index efbeba3977..de9e454401 100644 --- a/src/components/views/dialogs/ChangelogDialog.js +++ b/src/components/views/dialogs/ChangelogDialog.tsx @@ -16,21 +16,27 @@ Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; import request from 'browser-request'; import { _t } from '../../../languageHandler'; +import QuestionDialog from "./QuestionDialog"; +import Spinner from "../elements/Spinner"; + +interface IProps { + newVersion: string; + version: string; + onFinished: (success: boolean) => void; +} const REPOS = ['vector-im/element-web', 'matrix-org/matrix-react-sdk', 'matrix-org/matrix-js-sdk']; -export default class ChangelogDialog extends React.Component { +export default class ChangelogDialog extends React.Component { constructor(props) { super(props); this.state = {}; } - componentDidMount() { + public componentDidMount() { const version = this.props.newVersion.split('-'); const version2 = this.props.version.split('-'); if (version == null || version2 == null) return; @@ -44,25 +50,22 @@ export default class ChangelogDialog extends React.Component { this.setState({ [REPOS[i]]: response.statusText }); return; } - this.setState({[REPOS[i]]: JSON.parse(body).commits}); + this.setState({ [REPOS[i]]: JSON.parse(body).commits }); }); } } - _elementsForCommit(commit) { + private elementsForCommit(commit): JSX.Element { return (
  • - {commit.commit.message.split('\n')[0]} + { commit.commit.message.split('\n')[0] }
  • ); } - render() { - const Spinner = sdk.getComponent('views.elements.Spinner'); - const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog'); - + public render() { const logs = REPOS.map(repo => { let content; if (this.state[repo] == null) { @@ -72,23 +75,22 @@ export default class ChangelogDialog extends React.Component { msg: this.state[repo], }); } else { - content = this.state[repo].map(this._elementsForCommit); + content = this.state[repo].map(this.elementsForCommit); } return (
    -

    {repo}

    -
      {content}
    +

    { repo }

    +
      { content }
    ); }); const content = (
    - {this.props.version == null || this.props.newVersion == null ?

    {_t("Unavailable")}

    : logs} + { this.props.version == null || this.props.newVersion == null ?

    { _t("Unavailable") }

    : logs }
    ); - return ( { @@ -122,12 +122,12 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< if (index >= targets.length) return; // not important if (targets[index].trim() === "") { targets.splice(index, 1); - this.setState({emailTargets: targets}); + this.setState({ emailTargets: targets }); } }; private onShowPeopleClick = () => { - this.setState({showPeople: !this.state.showPeople}); + this.setState({ showPeople: !this.state.showPeople }); }; private setPersonToggle = (person: IPerson, selected: boolean) => { @@ -137,7 +137,7 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< } else if (!selected && targets.includes(person.userId)) { targets.splice(targets.indexOf(person.userId), 1); } - this.setState({userTargets: targets}); + this.setState({ userTargets: targets }); }; private renderPerson(person: IPerson, key: any) { @@ -156,8 +156,8 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< height={avatarSize} />
    - {person.user.name} - {person.userId} + { person.user.name } + { person.userId }
    this.setPersonToggle(person, e.target.checked)} />
    @@ -165,7 +165,7 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< } private onShowMorePeople = () => { - this.setState({numPeople: this.state.numPeople + 5}); // arbitrary increase + this.setState({ numPeople: this.state.numPeople + 5 }); // arbitrary increase }; public render() { @@ -187,7 +187,7 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< emailAddresses.push(( this.onAddressChange(e, emailAddresses.length)} label={emailAddresses.length > 0 ? _t("Add another email") : _t("Email address")} placeholder={emailAddresses.length > 0 ? _t("Add another email") : _t("Email address")} @@ -205,18 +205,21 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< people.push(( {_t("Show more")} + > + { _t("Show more") } + )); } } if (this.state.people.length > 0) { peopleIntro = (
    - {_t("People you know on %(brand)s", {brand: SdkConfig.get().brand})} + { _t("People you know on %(brand)s", { brand: SdkConfig.get().brand }) } - {this.state.showPeople ? _t("Hide") : _t("Show")} + { this.state.showPeople ? _t("Hide") : _t("Show") }
    ); @@ -225,25 +228,28 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< let buttonText = _t("Skip"); const targetCount = this.state.userTargets.length + this.state.emailTargets.length; if (targetCount > 0) { - buttonText = _t("Send %(count)s invites", {count: targetCount}); + buttonText = _t("Send %(count)s invites", { count: targetCount }); } return (
    - {emailAddresses} - {peopleIntro} - {people} + { emailAddresses } + { peopleIntro } + { people } {buttonText} + > + { buttonText } +
    diff --git a/src/components/views/dialogs/ConfirmAndWaitRedactDialog.js b/src/components/views/dialogs/ConfirmAndWaitRedactDialog.tsx similarity index 79% rename from src/components/views/dialogs/ConfirmAndWaitRedactDialog.js rename to src/components/views/dialogs/ConfirmAndWaitRedactDialog.tsx index 37d5510756..d21fde329c 100644 --- a/src/components/views/dialogs/ConfirmAndWaitRedactDialog.js +++ b/src/components/views/dialogs/ConfirmAndWaitRedactDialog.tsx @@ -15,9 +15,22 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import ConfirmRedactDialog from './ConfirmRedactDialog'; +import ErrorDialog from './ErrorDialog'; +import BaseDialog from "./BaseDialog"; +import Spinner from "../elements/Spinner"; + +interface IProps { + redact: () => Promise; + onFinished: (success: boolean) => void; +} + +interface IState { + isRedacting: boolean; + redactionErrorCode: string | number; +} /* * A dialog for confirming a redaction. @@ -32,7 +45,7 @@ import {replaceableComponent} from "../../../utils/replaceableComponent"; * To avoid this, we keep the dialog open as long as /redact is in progress. */ @replaceableComponent("views.dialogs.ConfirmAndWaitRedactDialog") -export default class ConfirmAndWaitRedactDialog extends React.PureComponent { +export default class ConfirmAndWaitRedactDialog extends React.PureComponent { constructor(props) { super(props); this.state = { @@ -41,16 +54,16 @@ export default class ConfirmAndWaitRedactDialog extends React.PureComponent { }; } - onParentFinished = async (proceed) => { + public onParentFinished = async (proceed: boolean): Promise => { if (proceed) { - this.setState({isRedacting: true}); + this.setState({ isRedacting: true }); try { await this.props.redact(); this.props.onFinished(true); } catch (error) { const code = error.errcode || error.statusCode; if (typeof code !== "undefined") { - this.setState({redactionErrorCode: code}); + this.setState({ redactionErrorCode: code }); } else { this.props.onFinished(true); } @@ -60,21 +73,18 @@ export default class ConfirmAndWaitRedactDialog extends React.PureComponent { } }; - render() { + public render() { if (this.state.isRedacting) { if (this.state.redactionErrorCode) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const code = this.state.redactionErrorCode; return ( ); } else { - const BaseDialog = sdk.getComponent("dialogs.BaseDialog"); - const Spinner = sdk.getComponent('elements.Spinner'); return ( ; } } diff --git a/src/components/views/dialogs/ConfirmRedactDialog.js b/src/components/views/dialogs/ConfirmRedactDialog.js deleted file mode 100644 index bd63d3acc1..0000000000 --- a/src/components/views/dialogs/ConfirmRedactDialog.js +++ /dev/null @@ -1,41 +0,0 @@ -/* -Copyright 2017 Vector Creations Ltd - -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 * as sdk from '../../../index'; -import { _t } from '../../../languageHandler'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; - -/* - * A dialog for confirming a redaction. - */ -@replaceableComponent("views.dialogs.ConfirmRedactDialog") -export default class ConfirmRedactDialog extends React.Component { - render() { - const TextInputDialog = sdk.getComponent('views.dialogs.TextInputDialog'); - return ( - - - ); - } -} diff --git a/src/components/views/dialogs/ConfirmRedactDialog.tsx b/src/components/views/dialogs/ConfirmRedactDialog.tsx new file mode 100644 index 0000000000..74b3320fdf --- /dev/null +++ b/src/components/views/dialogs/ConfirmRedactDialog.tsx @@ -0,0 +1,85 @@ +/* +Copyright 2017 Vector Creations Ltd + +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 { MatrixEvent } from 'matrix-js-sdk/src/models/event'; +import React from 'react'; +import { _t } from '../../../languageHandler'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import Modal from '../../../Modal'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import ErrorDialog from './ErrorDialog'; +import TextInputDialog from "./TextInputDialog"; + +interface IProps { + onFinished: (success: boolean) => void; +} + +/* + * A dialog for confirming a redaction. + */ +@replaceableComponent("views.dialogs.ConfirmRedactDialog") +export default class ConfirmRedactDialog extends React.Component { + render() { + return ( + + ); + } +} + +export function createRedactEventDialog({ + mxEvent, + onCloseDialog = () => {}, +}: { + mxEvent: MatrixEvent; + onCloseDialog?: () => void; +}) { + Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, { + onFinished: async (proceed: boolean, reason?: string) => { + if (!proceed) return; + + const cli = MatrixClientPeg.get(); + try { + onCloseDialog?.(); + await cli.redactEvent( + mxEvent.getRoomId(), + mxEvent.getId(), + undefined, + reason ? { reason } : {}, + ); + } catch (e) { + const code = e.errcode || e.statusCode; + // only show the dialog if failing for something other than a network error + // (e.g. no errcode or statusCode) as in that case the redactions end up in the + // detached queue and we show the room status bar to allow retry + if (typeof code !== "undefined") { + // display error message stating you couldn't delete this. + Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, { + title: _t('Error'), + description: _t('You cannot delete this message. (%(code)s)', { code }), + }); + } + } + }, + }, 'mx_Dialog_confirmredact'); +} diff --git a/src/components/views/dialogs/ConfirmUserActionDialog.js b/src/components/views/dialogs/ConfirmUserActionDialog.tsx similarity index 66% rename from src/components/views/dialogs/ConfirmUserActionDialog.js rename to src/components/views/dialogs/ConfirmUserActionDialog.tsx index 8059b9172a..7099556ac6 100644 --- a/src/components/views/dialogs/ConfirmUserActionDialog.js +++ b/src/components/views/dialogs/ConfirmUserActionDialog.tsx @@ -15,13 +15,34 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk/src/client'; -import * as sdk from '../../../index'; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { _t } from '../../../languageHandler'; import { GroupMemberType } from '../../../groups'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {mediaFromMxc} from "../../../customisations/Media"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../../customisations/Media"; +import MemberAvatar from '../avatars/MemberAvatar'; +import BaseAvatar from '../avatars/BaseAvatar'; +import BaseDialog from "./BaseDialog"; +import DialogButtons from "../elements/DialogButtons"; + +interface IProps { + // matrix-js-sdk (room) member object. Supply either this or 'groupMember' + member: RoomMember; + // group member object. Supply either this or 'member' + groupMember: GroupMemberType; + // needed if a group member is specified + matrixClient?: MatrixClient; + action: string; // eg. 'Ban' + title: string; // eg. 'Ban this user?' + + // Whether to display a text field for a reason + // If true, the second argument to onFinished will + // be the string entered. + askReason?: boolean; + danger?: boolean; + onFinished: (success: boolean, reason?: string) => void; +} /* * A dialog for confirming an operation on another user. @@ -32,58 +53,23 @@ import {mediaFromMxc} from "../../../customisations/Media"; * Also tweaks the style for 'dangerous' actions (albeit only with colour) */ @replaceableComponent("views.dialogs.ConfirmUserActionDialog") -export default class ConfirmUserActionDialog extends React.Component { - static propTypes = { - // matrix-js-sdk (room) member object. Supply either this or 'groupMember' - member: PropTypes.object, - // group member object. Supply either this or 'member' - groupMember: GroupMemberType, - // needed if a group member is specified - matrixClient: PropTypes.instanceOf(MatrixClient), - action: PropTypes.string.isRequired, // eg. 'Ban' - title: PropTypes.string.isRequired, // eg. 'Ban this user?' - - // Whether to display a text field for a reason - // If true, the second argument to onFinished will - // be the string entered. - askReason: PropTypes.bool, - danger: PropTypes.bool, - onFinished: PropTypes.func.isRequired, - }; +export default class ConfirmUserActionDialog extends React.Component { + private reasonField: React.RefObject = React.createRef(); static defaultProps = { danger: false, askReason: false, }; - constructor(props) { - super(props); - - this._reasonField = null; - } - - onOk = () => { - let reason; - if (this._reasonField) { - reason = this._reasonField.value; - } - this.props.onFinished(true, reason); + public onOk = (): void => { + this.props.onFinished(true, this.reasonField.current?.value); }; - onCancel = () => { + public onCancel = (): void => { this.props.onFinished(false); }; - _collectReasonField = e => { - this._reasonField = e; - }; - - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar"); - const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar"); - + public render() { const confirmButtonClass = this.props.danger ? 'danger' : ''; let reasonBox; @@ -92,7 +78,7 @@ export default class ConfirmUserActionDialog extends React.Component {
    @@ -118,7 +104,9 @@ export default class ConfirmUserActionDialog extends React.Component { } return ( - diff --git a/src/components/views/dialogs/ConfirmWipeDeviceDialog.js b/src/components/views/dialogs/ConfirmWipeDeviceDialog.tsx similarity index 72% rename from src/components/views/dialogs/ConfirmWipeDeviceDialog.js rename to src/components/views/dialogs/ConfirmWipeDeviceDialog.tsx index 333e1522f1..2577d5456d 100644 --- a/src/components/views/dialogs/ConfirmWipeDeviceDialog.js +++ b/src/components/views/dialogs/ConfirmWipeDeviceDialog.tsx @@ -15,29 +15,26 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import {_t} from "../../../languageHandler"; -import * as sdk from "../../../index"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { _t } from "../../../languageHandler"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseDialog from "./BaseDialog"; +import DialogButtons from "../elements/DialogButtons"; + +interface IProps { + onFinished: (success: boolean) => void; +} @replaceableComponent("views.dialogs.ConfirmWipeDeviceDialog") -export default class ConfirmWipeDeviceDialog extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - }; - - _onConfirm = () => { +export default class ConfirmWipeDeviceDialog extends React.Component { + private onConfirm = (): void => { this.props.onFinished(true); }; - _onDecline = () => { + private onDecline = (): void => { this.props.onFinished(false); }; render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return (

    - {_t( + { _t( "Clearing all data from this session is permanent. Encrypted messages will be lost " + "unless their keys have been backed up.", - )} + ) }

    ); diff --git a/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx b/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx index 9b4484d661..ccac45fbcc 100644 --- a/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx +++ b/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx @@ -23,9 +23,9 @@ import AccessibleButton from "../elements/AccessibleButton"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import InfoTooltip from "../elements/InfoTooltip"; import dis from "../../../dispatcher/dispatcher"; -import {showCommunityRoomInviteDialog} from "../../../RoomInvite"; +import { showCommunityRoomInviteDialog } from "../../../RoomInvite"; import GroupStore from "../../../stores/GroupStore"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps extends IDialogProps { } @@ -58,7 +58,7 @@ export default class CreateCommunityPrototypeDialog extends React.PureComponent< private onNameChange = (ev: ChangeEvent) => { const localpart = (ev.target.value || "").toLowerCase().replace(/[^a-z0-9.\-_]/g, '-'); - this.setState({name: ev.target.value, localpart}); + this.setState({ name: ev.target.value, localpart }); }; private onSubmit = async (ev) => { @@ -69,7 +69,7 @@ export default class CreateCommunityPrototypeDialog extends React.PureComponent< // We'll create the community now to see if it's taken, leaving it active in // the background for the user to look at while they invite people. - this.setState({busy: true}); + this.setState({ busy: true }); try { let avatarUrl = ''; // must be a string for synapse to accept it if (this.state.avatarFile) { @@ -85,7 +85,7 @@ export default class CreateCommunityPrototypeDialog extends React.PureComponent< }); // Ensure the tag gets selected now that we've created it - dis.dispatch({action: 'deselect_tags'}, true); + dis.dispatch({ action: 'deselect_tags' }, true); dis.dispatch({ action: 'select_tag', tag: result.group_id, @@ -123,13 +123,13 @@ export default class CreateCommunityPrototypeDialog extends React.PureComponent< private onAvatarChanged = (e: ChangeEvent) => { if (!e.target.files || !e.target.files.length) { - this.setState({avatarFile: null}); + this.setState({ avatarFile: null }); } else { - this.setState({busy: true}); + this.setState({ busy: true }); const file = e.target.files[0]; const reader = new FileReader(); reader.onload = (ev: ProgressEvent) => { - this.setState({avatarFile: file, busy: false, avatarPreview: ev.target.result as string}); + this.setState({ avatarFile: file, busy: false, avatarPreview: ev.target.result as string }); }; reader.readAsDataURL(file); } @@ -144,11 +144,11 @@ export default class CreateCommunityPrototypeDialog extends React.PureComponent< if (this.state.localpart) { communityId = ( - {_t("Community ID: +:%(domain)s", { + { _t("Community ID: +:%(domain)s", { domain: MatrixClientPeg.getHomeserverName(), }, { - localpart: () => {this.state.localpart}, - })} + localpart: () => { this.state.localpart }, + }) } - {_t("You can change this later if needed.")} + { _t("You can change this later if needed.") } ); if (this.state.error) { const classes = "mx_CreateCommunityPrototypeDialog_subtext mx_CreateCommunityPrototypeDialog_subtext_error"; helpText = ( - {this.state.error} + { this.state.error } ); } let preview = ; if (!this.state.avatarPreview) { - preview =
    + preview =
    ; } return ( @@ -193,31 +193,33 @@ export default class CreateCommunityPrototypeDialog extends React.PureComponent< placeholder={_t("Enter name")} label={_t("Enter name")} /> - {helpText} + { helpText } - {/*nbsp is to reserve the height of this element when there's nothing*/} -  {communityId} + { /*nbsp is to reserve the height of this element when there's nothing*/ } +  { communityId } - {_t("Create")} + { _t("Create") }
    - {preview} + { preview }
    - {_t("Add image (optional)")} + { _t("Add image (optional)") } - {_t("An image will help people identify your community.")} + { _t("An image will help people identify your community.") }
    diff --git a/src/components/views/dialogs/CreateGroupDialog.js b/src/components/views/dialogs/CreateGroupDialog.tsx similarity index 71% rename from src/components/views/dialogs/CreateGroupDialog.js rename to src/components/views/dialogs/CreateGroupDialog.tsx index e6c7a67aca..b1ea75d367 100644 --- a/src/components/views/dialogs/CreateGroupDialog.js +++ b/src/components/views/dialogs/CreateGroupDialog.tsx @@ -15,44 +15,52 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseDialog from "./BaseDialog"; +import Spinner from "../elements/Spinner"; + +interface IProps { + onFinished: (success: boolean) => void; +} + +interface IState { + groupName: string; + groupId: string; + groupIdError: string; + creating: boolean; + createError: Error; +} @replaceableComponent("views.dialogs.CreateGroupDialog") -export default class CreateGroupDialog extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - }; - - state = { +export default class CreateGroupDialog extends React.Component { + public state = { groupName: '', groupId: '', - groupError: null, + groupIdError: '', creating: false, createError: null, }; - _onGroupNameChange = e => { + private onGroupNameChange = (e: React.FormEvent): void => { this.setState({ - groupName: e.target.value, + groupName: e.currentTarget.value, }); }; - _onGroupIdChange = e => { + private onGroupIdChange = (e: React.FormEvent): void => { this.setState({ - groupId: e.target.value, + groupId: e.currentTarget.value, }); }; - _onGroupIdBlur = e => { - this._checkGroupId(); + private onGroupIdBlur = (): void => { + this.checkGroupId(); }; - _checkGroupId(e) { + private checkGroupId() { let error = null; if (!this.state.groupId) { error = _t("Community IDs cannot be empty."); @@ -67,16 +75,16 @@ export default class CreateGroupDialog extends React.Component { return error; } - _onFormSubmit = e => { + private onFormSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (this._checkGroupId()) return; + if (this.checkGroupId()) return; - const profile = {}; + const profile: any = {}; if (this.state.groupName !== '') { profile.name = this.state.groupName; } - this.setState({creating: true}); + this.setState({ creating: true }); MatrixClientPeg.get().createGroup({ localpart: this.state.groupId, profile: profile, @@ -88,20 +96,17 @@ export default class CreateGroupDialog extends React.Component { }); this.props.onFinished(true); }).catch((e) => { - this.setState({createError: e}); + this.setState({ createError: e }); }).finally(() => { - this.setState({creating: false}); + this.setState({ creating: false }); }); }; - _onCancel = () => { + private onCancel = () => { this.props.onFinished(false); }; render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const Spinner = sdk.getComponent('elements.Spinner'); - if (this.state.creating) { return ; } @@ -118,20 +123,25 @@ export default class CreateGroupDialog extends React.Component { } return ( - - +
    -
    @@ -144,10 +154,10 @@ export default class CreateGroupDialog extends React.Component { + @@ -162,7 +172,7 @@ export default class CreateGroupDialog extends React.Component {
    -
    diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx index cce6b6c34c..0da5f189bf 100644 --- a/src/components/views/dialogs/CreateRoomDialog.tsx +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -15,31 +15,36 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {ChangeEvent, createRef, KeyboardEvent, SyntheticEvent} from "react"; -import {Room} from "matrix-js-sdk/src/models/room"; +import React, { ChangeEvent, createRef, KeyboardEvent, SyntheticEvent } from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { JoinRule, Preset, Visibility } from "matrix-js-sdk/src/@types/partials"; import SdkConfig from '../../../SdkConfig'; -import withValidation, {IFieldState} from '../elements/Validation'; -import {_t} from '../../../languageHandler'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import {Key} from "../../../Keyboard"; -import {IOpts, Preset, privateShouldBeEncrypted, Visibility} from "../../../createRoom"; -import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import withValidation, { IFieldState } from '../elements/Validation'; +import { _t } from '../../../languageHandler'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import { Key } from "../../../Keyboard"; +import { IOpts, privateShouldBeEncrypted } from "../../../createRoom"; +import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; import Field from "../elements/Field"; import RoomAliasField from "../elements/RoomAliasField"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; import DialogButtons from "../elements/DialogButtons"; import BaseDialog from "../dialogs/BaseDialog"; +import SpaceStore from "../../../stores/SpaceStore"; +import JoinRuleDropdown from "../elements/JoinRuleDropdown"; interface IProps { defaultPublic?: boolean; defaultName?: string; parentSpace?: Room; + defaultEncrypted?: boolean; onFinished(proceed: boolean, opts?: IOpts): void; } interface IState { + joinRule: JoinRule; isPublic: boolean; isEncrypted: boolean; name: string; @@ -53,16 +58,27 @@ interface IState { @replaceableComponent("views.dialogs.CreateRoomDialog") export default class CreateRoomDialog extends React.Component { + private readonly supportsRestricted: boolean; private nameField = createRef(); private aliasField = createRef(); constructor(props) { super(props); + this.supportsRestricted = this.props.parentSpace && !!SpaceStore.instance.restrictedJoinRuleSupport?.preferred; + + let joinRule = JoinRule.Invite; + if (this.props.defaultPublic) { + joinRule = JoinRule.Public; + } else if (this.supportsRestricted) { + joinRule = JoinRule.Restricted; + } + const config = SdkConfig.get(); this.state = { isPublic: this.props.defaultPublic || false, - isEncrypted: privateShouldBeEncrypted(), + isEncrypted: this.props.defaultEncrypted ?? privateShouldBeEncrypted(), + joinRule, name: this.props.defaultName || "", topic: "", alias: "", @@ -72,7 +88,7 @@ export default class CreateRoomDialog extends React.Component { canChangeEncryption: true, }; - MatrixClientPeg.get().doesServerForceEncryptionForPreset("private") + MatrixClientPeg.get().doesServerForceEncryptionForPreset(Preset.PrivateChat) .then(isForced => this.setState({ canChangeEncryption: !isForced })); } @@ -80,13 +96,18 @@ export default class CreateRoomDialog extends React.Component { const opts: IOpts = {}; const createOpts: IOpts["createOpts"] = opts.createOpts = {}; createOpts.name = this.state.name; - if (this.state.isPublic) { + + if (this.state.joinRule === JoinRule.Public) { createOpts.visibility = Visibility.Public; createOpts.preset = Preset.PublicChat; opts.guestAccess = false; const { alias } = this.state; createOpts.room_alias_name = alias.substr(1, alias.indexOf(":") - 1); + } else { + // If we cannot change encryption we pass `true` for safety, the server should automatically do this for us. + opts.encryption = this.state.canChangeEncryption ? this.state.isEncrypted : true; } + if (this.state.topic) { createOpts.topic = this.state.topic; } @@ -94,22 +115,13 @@ export default class CreateRoomDialog extends React.Component { createOpts.creation_content = { 'm.federate': false }; } - if (!this.state.isPublic) { - if (this.state.canChangeEncryption) { - opts.encryption = this.state.isEncrypted; - } else { - // the server should automatically do this for us, but for safety - // we'll demand it too. - opts.encryption = true; - } - } - if (CommunityPrototypeStore.instance.getSelectedCommunityId()) { opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId(); } - if (this.props.parentSpace) { - opts.parentSpace = this.props.parentSpace; + opts.parentSpace = this.props.parentSpace; + if (this.props.parentSpace && this.state.joinRule === JoinRule.Restricted) { + opts.joinRule = JoinRule.Restricted; } return opts; @@ -136,9 +148,9 @@ export default class CreateRoomDialog extends React.Component { if (activeElement) { activeElement.blur(); } - await this.nameField.current.validate({allowEmpty: false}); + await this.nameField.current.validate({ allowEmpty: false }); if (this.aliasField.current) { - await this.aliasField.current.validate({allowEmpty: false}); + await this.aliasField.current.validate({ allowEmpty: false }); } // Validation and state updates are async, so we need to wait for them to complete // first. Queue a `setState` callback and wait for it to resolve. @@ -171,8 +183,8 @@ export default class CreateRoomDialog extends React.Component { this.setState({ topic: ev.target.value }); }; - private onPublicChange = (isPublic: boolean) => { - this.setState({ isPublic }); + private onJoinRuleChange = (joinRule: JoinRule) => { + this.setState({ joinRule }); }; private onEncryptedChange = (isEncrypted: boolean) => { @@ -193,7 +205,7 @@ export default class CreateRoomDialog extends React.Component { private onNameValidate = async (fieldState: IFieldState) => { const result = await CreateRoomDialog.validateRoomName(fieldState); - this.setState({nameIsValid: result.valid}); + this.setState({ nameIsValid: result.valid }); return result; }; @@ -209,7 +221,7 @@ export default class CreateRoomDialog extends React.Component { render() { let aliasField; - if (this.state.isPublic) { + if (this.state.joinRule === JoinRule.Public) { const domain = MatrixClientPeg.get().getDomain(); aliasField = (
    @@ -223,19 +235,52 @@ export default class CreateRoomDialog extends React.Component { ); } - let publicPrivateLabel =

    {_t( - "Private rooms can be found and joined by invitation only. Public rooms can be " + - "found and joined by anyone.", - )}

    ; + let publicPrivateLabel: JSX.Element; if (CommunityPrototypeStore.instance.getSelectedCommunityId()) { - publicPrivateLabel =

    {_t( - "Private rooms can be found and joined by invitation only. Public rooms can be " + - "found and joined by anyone in this community.", - )}

    ; + publicPrivateLabel =

    + { _t( + "Private rooms can be found and joined by invitation only. Public rooms can be " + + "found and joined by anyone in this community.", + ) } +

    ; + } else if (this.state.joinRule === JoinRule.Restricted) { + publicPrivateLabel =

    + { _t( + "Everyone in will be able to find and join this room.", {}, { + SpaceName: () => { this.props.parentSpace.name }, + }, + ) } +   + { _t("You can change this at any time from room settings.") } +

    ; + } else if (this.state.joinRule === JoinRule.Public && this.props.parentSpace) { + publicPrivateLabel =

    + { _t( + "Anyone will be able to find and join this room, not just members of .", {}, { + SpaceName: () => { this.props.parentSpace.name }, + }, + ) } +   + { _t("You can change this at any time from room settings.") } +

    ; + } else if (this.state.joinRule === JoinRule.Public) { + publicPrivateLabel =

    + { _t("Anyone will be able to find and join this room.") } +   + { _t("You can change this at any time from room settings.") } +

    ; + } else if (this.state.joinRule === JoinRule.Invite) { + publicPrivateLabel =

    + { _t( + "Only people invited will be able to find and join this room.", + ) } +   + { _t("You can change this at any time from room settings.") } +

    ; } let e2eeSection; - if (!this.state.isPublic) { + if (this.state.joinRule !== JoinRule.Public) { let microcopy; if (privateShouldBeEncrypted()) { if (this.state.canChangeEncryption) { @@ -249,7 +294,7 @@ export default class CreateRoomDialog extends React.Component { } e2eeSection = { ); } - let title = this.state.isPublic ? _t('Create a public room') : _t('Create a private room'); + let title = _t("Create a room"); if (CommunityPrototypeStore.instance.getSelectedCommunityId()) { const name = CommunityPrototypeStore.instance.getSelectedCommunityName(); - title = _t("Create a room in %(communityName)s", {communityName: name}); + title = _t("Create a room in %(communityName)s", { communityName: name }); + } else if (!this.props.parentSpace) { + title = this.state.joinRule === JoinRule.Public ? _t('Create a public room') : _t('Create a private room'); } + return ( - +
    { value={this.state.topic} className="mx_CreateRoomDialog_topic" /> - + { publicPrivateLabel } { e2eeSection } { aliasField } @@ -312,12 +363,12 @@ export default class CreateRoomDialog extends React.Component { -

    {federateLabel}

    +

    { federateLabel }

    diff --git a/src/components/views/dialogs/CreateSpaceFromCommunityDialog.tsx b/src/components/views/dialogs/CreateSpaceFromCommunityDialog.tsx new file mode 100644 index 0000000000..e74082427f --- /dev/null +++ b/src/components/views/dialogs/CreateSpaceFromCommunityDialog.tsx @@ -0,0 +1,340 @@ +/* +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, { useEffect, useRef, useState } from "react"; +import { JoinRule } from "matrix-js-sdk/src/@types/partials"; +import { EventType } from "matrix-js-sdk/src/@types/event"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { _t } from '../../../languageHandler'; +import BaseDialog from "./BaseDialog"; +import AccessibleButton from "../elements/AccessibleButton"; +import { createSpace, SpaceCreateForm } from "../spaces/SpaceCreateMenu"; +import JoinRuleDropdown from "../elements/JoinRuleDropdown"; +import Field from "../elements/Field"; +import RoomAliasField from "../elements/RoomAliasField"; +import { GroupMember } from "../right_panel/UserInfo"; +import { parseMembersResponse, parseRoomsResponse } from "../../../stores/GroupStore"; +import { calculateRoomVia, makeRoomPermalink } from "../../../utils/permalinks/Permalinks"; +import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; +import Spinner from "../elements/Spinner"; +import { mediaFromMxc } from "../../../customisations/Media"; +import SpaceStore from "../../../stores/SpaceStore"; +import Modal from "../../../Modal"; +import InfoDialog from "./InfoDialog"; +import dis from "../../../dispatcher/dispatcher"; +import { Action } from "../../../dispatcher/actions"; +import { UserTab } from "./UserSettingsDialog"; +import TagOrderActions from "../../../actions/TagOrderActions"; + +interface IProps { + matrixClient: MatrixClient; + groupId: string; + onFinished(spaceId?: string): void; +} + +export const CreateEventField = "io.element.migrated_from_community"; + +interface IGroupRoom { + displayname: string; + name?: string; + roomId: string; + canonicalAlias?: string; + avatarUrl?: string; + topic?: string; + numJoinedMembers?: number; + worldReadable?: boolean; + guestCanJoin?: boolean; + isPublic?: boolean; +} + +/* eslint-disable camelcase */ +export interface IGroupSummary { + profile: { + avatar_url?: string; + is_openly_joinable?: boolean; + is_public?: boolean; + long_description: string; + name: string; + short_description: string; + }; + rooms_section: { + rooms: unknown[]; + categories: Record; + total_room_count_estimate: number; + }; + user: { + is_privileged: boolean; + is_public: boolean; + is_publicised: boolean; + membership: string; + }; + users_section: { + users: unknown[]; + roles: Record; + total_user_count_estimate: number; + }; +} +/* eslint-enable camelcase */ + +const CreateSpaceFromCommunityDialog: React.FC = ({ matrixClient: cli, groupId, onFinished }) => { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const [avatar, setAvatar] = useState(null); // undefined means to remove avatar + const [name, setName] = useState(""); + const spaceNameField = useRef(); + const [alias, setAlias] = useState("#" + groupId.substring(1, groupId.indexOf(":")) + ":" + cli.getDomain()); + const spaceAliasField = useRef(); + const [topic, setTopic] = useState(""); + const [joinRule, setJoinRule] = useState(JoinRule.Public); + + const groupSummary = useAsyncMemo(() => cli.getGroupSummary(groupId), [groupId]); + useEffect(() => { + if (groupSummary) { + setName(groupSummary.profile.name || ""); + setTopic(groupSummary.profile.short_description || ""); + setJoinRule(groupSummary.profile.is_openly_joinable ? JoinRule.Public : JoinRule.Invite); + setLoading(false); + } + }, [groupSummary]); + + if (loading) { + return ; + } + + const onCreateSpaceClick = async (e) => { + e.preventDefault(); + if (busy) return; + + setError(null); + setBusy(true); + + // require & validate the space name field + if (!(await spaceNameField.current.validate({ allowEmpty: false }))) { + setBusy(false); + spaceNameField.current.focus(); + spaceNameField.current.validate({ allowEmpty: false, focused: true }); + return; + } + // validate the space name alias field but do not require it + if (joinRule === JoinRule.Public && !(await spaceAliasField.current.validate({ allowEmpty: true }))) { + setBusy(false); + spaceAliasField.current.focus(); + spaceAliasField.current.validate({ allowEmpty: true, focused: true }); + return; + } + + try { + const [rooms, members, invitedMembers] = await Promise.all([ + cli.getGroupRooms(groupId).then(parseRoomsResponse) as Promise, + cli.getGroupUsers(groupId).then(parseMembersResponse) as Promise, + cli.getGroupInvitedUsers(groupId).then(parseMembersResponse) as Promise, + ]); + + const viaMap = new Map(); + for (const { roomId, canonicalAlias } of rooms) { + const room = cli.getRoom(roomId); + if (room) { + viaMap.set(roomId, calculateRoomVia(room)); + } else if (canonicalAlias) { + try { + const { servers } = await cli.getRoomIdForAlias(canonicalAlias); + viaMap.set(roomId, servers); + } catch (e) { + console.warn("Failed to resolve alias during community migration", e); + } + } + + if (!viaMap.get(roomId)?.length) { + // XXX: lets guess the via, this might end up being incorrect. + const str = canonicalAlias || roomId; + viaMap.set(roomId, [str.substring(1, str.indexOf(":"))]); + } + } + + const spaceAvatar = avatar !== undefined ? avatar : groupSummary.profile.avatar_url; + const roomId = await createSpace(name, joinRule === JoinRule.Public, alias, topic, spaceAvatar, { + creation_content: { + [CreateEventField]: groupId, + }, + initial_state: rooms.map(({ roomId }) => ({ + type: EventType.SpaceChild, + state_key: roomId, + content: { + via: viaMap.get(roomId) || [], + }, + })), + invite: [...members, ...invitedMembers].map(m => m.userId).filter(m => m !== cli.getUserId()), + }, { + andView: false, + }); + + // eagerly remove it from the community panel + dis.dispatch(TagOrderActions.removeTag(cli, groupId)); + + // don't bother awaiting this, as we don't hugely care if it fails + cli.setGroupProfile(groupId, { + ...groupSummary.profile, + long_description: `

    ` + + _t("This community has been upgraded into a Space") + `


    ` + + groupSummary.profile.long_description, + } as IGroupSummary["profile"]).catch(e => { + console.warn("Failed to update community profile during migration", e); + }); + + onFinished(roomId); + + const onSpaceClick = () => { + dis.dispatch({ + action: "view_room", + room_id: roomId, + }); + }; + + const onPreferencesClick = () => { + dis.dispatch({ + action: Action.ViewUserSettings, + initialTabId: UserTab.Preferences, + }); + }; + + let spacesDisabledCopy; + if (!SpaceStore.spacesEnabled) { + spacesDisabledCopy = _t("To view Spaces, hide communities in Preferences", {}, { + a: sub => { sub }, + }); + } + + Modal.createDialog(InfoDialog, { + title: _t("Space created"), + description: <> +
    +

    + { _t(" has been made and everyone who was a part of the community has " + + "been invited to it.", {}, { + SpaceName: () => + { name } + , + }) } +   + { spacesDisabledCopy } +

    +

    + { _t("To create a Space from another community, just pick the community in Preferences.") } +

    + , + button: _t("Preferences"), + onFinished: (openPreferences: boolean) => { + if (openPreferences) { + onPreferencesClick(); + } + }, + }, "mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog"); + } catch (e) { + console.error(e); + setError(e); + } + + setBusy(false); + }; + + let footer; + if (error) { + footer = <> + + + +
    { _t("Failed to migrate community") }
    +
    { _t("Try again") }
    +
    + + + { _t("Retry") } + + ; + } else { + footer = <> + onFinished()}> + { _t("Cancel") } + + + { busy ? _t("Creating...") : _t("Create Space") } + + ; + } + + return +
    +

    + { _t("A link to the Space will be put in your community description.") } +   + { _t("All rooms will be added and all community members will be invited.") } +

    +

    + { _t("Flair won't be available in Spaces for the foreseeable future.") } +

    + + +

    { _t("This description will be shown to people when they view your space") }

    + +

    { joinRule === JoinRule.Public + ? _t("Open space for anyone, best for communities") + : _t("Invite only, best for yourself or teams") + }

    + { joinRule !== JoinRule.Public && +
    + } + +
    + +
    + { footer } +
    + ; +}; + +export default CreateSpaceFromCommunityDialog; + diff --git a/src/components/views/dialogs/CreateSubspaceDialog.tsx b/src/components/views/dialogs/CreateSubspaceDialog.tsx new file mode 100644 index 0000000000..0d7facb476 --- /dev/null +++ b/src/components/views/dialogs/CreateSubspaceDialog.tsx @@ -0,0 +1,187 @@ +/* +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, { useRef, useState } from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { JoinRule } from "matrix-js-sdk/src/@types/partials"; + +import { _t } from '../../../languageHandler'; +import BaseDialog from "./BaseDialog"; +import AccessibleButton from "../elements/AccessibleButton"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { BetaPill } from "../beta/BetaCard"; +import Field from "../elements/Field"; +import RoomAliasField from "../elements/RoomAliasField"; +import SpaceStore from "../../../stores/SpaceStore"; +import { createSpace, SpaceCreateForm } from "../spaces/SpaceCreateMenu"; +import { SubspaceSelector } from "./AddExistingToSpaceDialog"; +import JoinRuleDropdown from "../elements/JoinRuleDropdown"; + +interface IProps { + space: Room; + onAddExistingSpaceClick(): void; + onFinished(added?: boolean): void; +} + +const CreateSubspaceDialog: React.FC = ({ space, onAddExistingSpaceClick, onFinished }) => { + const [parentSpace, setParentSpace] = useState(space); + + const [busy, setBusy] = useState(false); + const [name, setName] = useState(""); + const spaceNameField = useRef(); + const [alias, setAlias] = useState(""); + const spaceAliasField = useRef(); + const [avatar, setAvatar] = useState(null); + const [topic, setTopic] = useState(""); + + const supportsRestricted = !!SpaceStore.instance.restrictedJoinRuleSupport?.preferred; + + const spaceJoinRule = space.getJoinRule(); + let defaultJoinRule = JoinRule.Invite; + if (spaceJoinRule === JoinRule.Public) { + defaultJoinRule = JoinRule.Public; + } else if (supportsRestricted) { + defaultJoinRule = JoinRule.Restricted; + } + const [joinRule, setJoinRule] = useState(defaultJoinRule); + + const onCreateSubspaceClick = async (e) => { + e.preventDefault(); + if (busy) return; + + setBusy(true); + // require & validate the space name field + if (!(await spaceNameField.current.validate({ allowEmpty: false }))) { + spaceNameField.current.focus(); + spaceNameField.current.validate({ allowEmpty: false, focused: true }); + setBusy(false); + return; + } + // validate the space name alias field but do not require it + if (joinRule === JoinRule.Public && !(await spaceAliasField.current.validate({ allowEmpty: true }))) { + spaceAliasField.current.focus(); + spaceAliasField.current.validate({ allowEmpty: true, focused: true }); + setBusy(false); + return; + } + + try { + await createSpace(name, joinRule === JoinRule.Public, alias, topic, avatar, {}, { parentSpace, joinRule }); + + onFinished(true); + } catch (e) { + console.error(e); + } + }; + + let joinRuleMicrocopy: JSX.Element; + if (joinRule === JoinRule.Restricted) { + joinRuleMicrocopy =

    + { _t( + "Anyone in will be able to find and join.", {}, { + SpaceName: () => { parentSpace.name }, + }, + ) } +

    ; + } else if (joinRule === JoinRule.Public) { + joinRuleMicrocopy =

    + { _t( + "Anyone will be able to find and join this space, not just members of .", {}, { + SpaceName: () => { parentSpace.name }, + }, + ) } +

    ; + } else if (joinRule === JoinRule.Invite) { + joinRuleMicrocopy =

    + { _t("Only people invited will be able to find and join this space.") } +

    ; + } + + return + )} + className="mx_CreateSubspaceDialog" + contentId="mx_CreateSubspaceDialog" + onFinished={onFinished} + fixedWidth={false} + > + +
    +
    + + { _t("Add a space to a space you manage.") } +
    + + + + { joinRuleMicrocopy } + +
    + +
    +
    +
    { _t("Want to add an existing space instead?") }
    + { + onAddExistingSpaceClick(); + onFinished(); + }} + > + { _t("Add existing space") } + +
    + + onFinished(false)}> + { _t("Cancel") } + + + { busy ? _t("Adding...") : _t("Add") } + +
    +
    +
    ; +}; + +export default CreateSubspaceDialog; + diff --git a/src/components/views/dialogs/CryptoStoreTooNewDialog.js b/src/components/views/dialogs/CryptoStoreTooNewDialog.tsx similarity index 82% rename from src/components/views/dialogs/CryptoStoreTooNewDialog.js rename to src/components/views/dialogs/CryptoStoreTooNewDialog.tsx index 6336c635e4..3bb78233ea 100644 --- a/src/components/views/dialogs/CryptoStoreTooNewDialog.js +++ b/src/components/views/dialogs/CryptoStoreTooNewDialog.tsx @@ -16,17 +16,21 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; import Modal from '../../../Modal'; +import BaseDialog from "./BaseDialog"; +import DialogButtons from "../elements/DialogButtons"; +import QuestionDialog from "./QuestionDialog"; +import { IDialogProps } from "./IDialogProps"; -export default (props) => { +interface IProps extends IDialogProps {} + +const CryptoStoreTooNewDialog: React.FC = (props: IProps) => { const brand = SdkConfig.get().brand; const _onLogoutClicked = () => { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); Modal.createTrackedDialog('Logout e2e db too new', '', QuestionDialog, { title: _t("Sign out"), description: _t( @@ -39,8 +43,8 @@ export default (props) => { focus: false, onFinished: (doLogout) => { if (doLogout) { - dis.dispatch({action: 'logout'}); - props.onFinished(); + dis.dispatch({ action: 'logout' }); + props.onFinished(true); } }, }); @@ -54,8 +58,6 @@ export default (props) => { { brand }, ); - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return ( { hasCancel={false} onPrimaryButtonClick={props.onFinished} > - ); }; + +export default CryptoStoreTooNewDialog; diff --git a/src/components/views/dialogs/DeactivateAccountDialog.js b/src/components/views/dialogs/DeactivateAccountDialog.tsx similarity index 72% rename from src/components/views/dialogs/DeactivateAccountDialog.js rename to src/components/views/dialogs/DeactivateAccountDialog.tsx index 4e52549d51..6548bd78fc 100644 --- a/src/components/views/dialogs/DeactivateAccountDialog.js +++ b/src/components/views/dialogs/DeactivateAccountDialog.tsx @@ -16,20 +16,37 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; +import { AuthType, IAuthData } from 'matrix-js-sdk/src/interactive-auth'; -import * as sdk from '../../../index'; import Analytics from '../../../Analytics'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; import * as Lifecycle from '../../../Lifecycle'; import { _t } from '../../../languageHandler'; -import InteractiveAuth, {ERROR_USER_CANCELLED} from "../../structures/InteractiveAuth"; -import {DEFAULT_PHASE, PasswordAuthEntry, SSOAuthEntry} from "../auth/InteractiveAuthEntryComponents"; +import InteractiveAuth, { ERROR_USER_CANCELLED } from "../../structures/InteractiveAuth"; +import { DEFAULT_PHASE, PasswordAuthEntry, SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents"; import StyledCheckbox from "../elements/StyledCheckbox"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseDialog from "./BaseDialog"; + +interface IProps { + onFinished: (success: boolean) => void; +} + +interface IState { + shouldErase: boolean; + errStr: string; + authData: any; // for UIA + authEnabled: boolean; // see usages for information + + // A few strings that are passed to InteractiveAuth for design or are displayed + // next to the InteractiveAuth component. + bodyText: string; + continueText: string; + continueKind: string; +} @replaceableComponent("views.dialogs.DeactivateAccountDialog") -export default class DeactivateAccountDialog extends React.Component { +export default class DeactivateAccountDialog extends React.Component { constructor(props) { super(props); @@ -46,10 +63,10 @@ export default class DeactivateAccountDialog extends React.Component { continueKind: null, }; - this._initAuth(/* shouldErase= */false); + this.initAuth(/* shouldErase= */false); } - _onStagePhaseChange = (stage, phase) => { + private onStagePhaseChange = (stage: AuthType, phase: string): void => { const dialogAesthetics = { [SSOAuthEntry.PHASE_PREAUTH]: { body: _t("Confirm your account deactivation by using Single Sign On to prove your identity."), @@ -84,22 +101,25 @@ export default class DeactivateAccountDialog extends React.Component { if (phaseAesthetics && phaseAesthetics.continueText) continueText = phaseAesthetics.continueText; if (phaseAesthetics && phaseAesthetics.continueKind) continueKind = phaseAesthetics.continueKind; } - this.setState({bodyText, continueText, continueKind}); + this.setState({ bodyText, continueText, continueKind }); }; - _onUIAuthFinished = (success, result, extra) => { + private onUIAuthFinished = (success: boolean, result: Error) => { if (success) return; // great! makeRequest() will be called too. if (result === ERROR_USER_CANCELLED) { - this._onCancel(); + this.onCancel(); return; } - console.error("Error during UI Auth:", {result, extra}); - this.setState({errStr: _t("There was a problem communicating with the server. Please try again.")}); + console.error("Error during UI Auth:", { result }); + this.setState({ errStr: _t("There was a problem communicating with the server. Please try again.") }); }; - _onUIAuthComplete = (auth) => { + private onUIAuthComplete = (auth: IAuthData): void => { + // XXX: this should be returning a promise to maintain the state inside the state machine correct + // but given that a deactivation is followed by a local logout and all object instances being thrown away + // this isn't done. MatrixClientPeg.get().deactivateAccount(auth, this.state.shouldErase).then(r => { // Deactivation worked - logout & close this dialog Analytics.trackEvent('Account', 'Deactivate Account'); @@ -107,13 +127,13 @@ export default class DeactivateAccountDialog extends React.Component { this.props.onFinished(true); }).catch(e => { console.error(e); - this.setState({errStr: _t("There was a problem communicating with the server. Please try again.")}); + this.setState({ errStr: _t("There was a problem communicating with the server. Please try again.") }); }); }; - _onEraseFieldChange = (ev) => { + private onEraseFieldChange = (ev: React.FormEvent): void => { this.setState({ - shouldErase: ev.target.checked, + shouldErase: ev.currentTarget.checked, // Disable the auth form because we're going to have to reinitialize the auth // information. We do this because we can't modify the parameters in the UIA @@ -123,34 +143,32 @@ export default class DeactivateAccountDialog extends React.Component { }); // As mentioned above, set up for auth again to get updated UIA session info - this._initAuth(/* shouldErase= */ev.target.checked); + this.initAuth(/* shouldErase= */ev.currentTarget.checked); }; - _onCancel() { + private onCancel(): void { this.props.onFinished(false); } - _initAuth(shouldErase) { + private initAuth(shouldErase: boolean): void { MatrixClientPeg.get().deactivateAccount(null, shouldErase).then(r => { // If we got here, oops. The server didn't require any auth. // Our application lifecycle will catch the error and do the logout bits. // We'll try to log something in an vain attempt to record what happened (storage // is also obliterated on logout). console.warn("User's account got deactivated without confirmation: Server had no auth"); - this.setState({errStr: _t("Server did not require any authentication")}); + this.setState({ errStr: _t("Server did not require any authentication") }); }).catch(e => { if (e && e.httpStatus === 401 && e.data) { // Valid UIA response - this.setState({authData: e.data, authEnabled: true}); + this.setState({ authData: e.data, authEnabled: true }); } else { - this.setState({errStr: _t("Server did not return valid authentication information.")}); + this.setState({ errStr: _t("Server did not return valid authentication information.") }); } }); } - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - + public render() { let error = null; if (this.state.errStr) { error =
    @@ -158,17 +176,19 @@ export default class DeactivateAccountDialog extends React.Component {
    ; } - let auth =
    {_t("Loading...")}
    ; + let auth =
    { _t("Loading...") }
    ; if (this.state.authData && this.state.authEnabled) { auth = (
    - {this.state.bodyText} + { this.state.bodyText } @@ -214,20 +234,20 @@ export default class DeactivateAccountDialog extends React.Component {

    - {_t( + { _t( "Please forget all messages I have sent when my account is deactivated " + "(Warning: this will cause future users to see an incomplete view " + "of conversations)", {}, { b: (sub) => { sub } }, - )} + ) }

    - {error} - {auth} + { error } + { auth }
    @@ -235,7 +255,3 @@ export default class DeactivateAccountDialog extends React.Component { ); } } - -DeactivateAccountDialog.propTypes = { - onFinished: PropTypes.func.isRequired, -}; diff --git a/src/components/views/dialogs/DevtoolsDialog.tsx b/src/components/views/dialogs/DevtoolsDialog.tsx index fdbf6a36fc..7f34b75055 100644 --- a/src/components/views/dialogs/DevtoolsDialog.tsx +++ b/src/components/views/dialogs/DevtoolsDialog.tsx @@ -16,7 +16,6 @@ limitations under the License. */ import React, { useState, useEffect, ChangeEvent, MouseEvent } from 'react'; -import * as sdk from '../../../index'; import SyntaxHighlight from '../elements/SyntaxHighlight'; import { _t } from '../../../languageHandler'; import Field from "../elements/Field"; @@ -42,6 +41,10 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { SettingLevel } from '../../../settings/SettingLevel'; +import BaseDialog from "./BaseDialog"; +import TruncatedList from "../elements/TruncatedList"; + +import { logger } from "matrix-js-sdk/src/logger"; interface IGenericEditorProps { onBack: () => void; @@ -62,13 +65,13 @@ abstract class GenericEditor< } else { this.props.onBack(); } - } + }; protected onChange = (e: ChangeEvent) => { // @ts-ignore: Unsure how to convince TS this is okay when the state // type can be extended. - this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value}); - } + this.setState({ [e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }); + }; protected abstract send(); @@ -119,7 +122,7 @@ export class SendCustomEvent extends GenericEditor - +
    { !this.state.message && } - { showTglFlip &&
    - + - +
    { !this.state.message && } - { !this.state.message &&
    - + - + key={this.props.children[0] ? this.props.children[0].key : ''} + /> { this.setState({ editing: true }); - } + }; private onQueryEventType = (filterEventType: string) => { this.setState({ queryEventType: filterEventType }); - } + }; private onQueryStateKey = (filterStateKey: string) => { this.setState({ queryStateKey: filterStateKey }); - } + }; render() { if (this.state.event) { if (this.state.editing) { - return ; + return ; } return
    @@ -494,7 +527,7 @@ class RoomStateExplorer extends React.PureComponent - {eventType} + { eventType } ; }) } @@ -525,11 +558,11 @@ class RoomStateExplorer extends React.PureComponent { @@ -570,19 +603,19 @@ class AccountDataExplorer extends React.PureComponent) => { - this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value}); - } + this.setState({ [e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }); + }; private editEv = () => { this.setState({ editing: true }); - } + }; private onQueryEventType = (queryEventType: string) => { this.setState({ queryEventType }); - } + }; render() { if (this.state.event) { @@ -594,7 +627,9 @@ class AccountDataExplorer extends React.PureComponent; + }} + forceMode={true} + />; } return
    @@ -630,8 +665,10 @@ class AccountDataExplorer extends React.PureComponent
    -
    - + { this.setState({ query }); - } + }; render() { return
    @@ -704,7 +741,7 @@ const PHASE_MAP = { const VerificationRequestExplorer: React.FC<{ txnId: string; request: VerificationRequest; -}> = ({txnId, request}) => { +}> = ({ txnId, request }) => { const [, updateState] = useState(); const [timeout, setRequestTimeout] = useState(request.timeout); @@ -726,20 +763,20 @@ const VerificationRequestExplorer: React.FC<{ return (
    Transaction
    -
    {txnId}
    +
    { txnId }
    Phase
    -
    {PHASE_MAP[request.phase] || request.phase}
    +
    { PHASE_MAP[request.phase] || request.phase }
    Timeout
    -
    {Math.floor(timeout / 1000)}
    +
    { Math.floor(timeout / 1000) }
    Methods
    -
    {request.methods && request.methods.join(", ")}
    +
    { request.methods && request.methods.join(", ") }
    requestingUserId
    -
    {request.requestingUserId}
    +
    { request.requestingUserId }
    observeOnly
    -
    {JSON.stringify(request.observeOnly)}
    +
    { JSON.stringify(request.observeOnly) }
    ); -} +}; class VerificationExplorer extends React.PureComponent { static getLabel() { @@ -751,7 +788,7 @@ class VerificationExplorer extends React.PureComponent { private onNewRequest = () => { this.forceUpdate(); - } + }; componentDidMount() { const cli = this.context; @@ -766,17 +803,17 @@ class VerificationExplorer extends React.PureComponent { render() { const cli = this.context; const room = this.props.room; - const inRoomChannel = cli.crypto._inRoomVerificationRequests; + const inRoomChannel = cli.crypto.inRoomVerificationRequests; const inRoomRequests = (inRoomChannel._requestsByRoomId || new Map()).get(room.roomId) || new Map(); return (
    - {Array.from(inRoomRequests.entries()).reverse().map(([txnId, request]) => + { Array.from(inRoomRequests.entries()).reverse().map(([txnId, request]) => , - )} + ) }
    - +
    ); } @@ -806,17 +843,17 @@ class WidgetExplorer extends React.Component { - this.setState({query}); + this.setState({ query }); }; private onEditWidget = (widget: IApp) => { - this.setState({editWidget: widget}); + this.setState({ editWidget: widget }); }; private onBack = () => { const widgets = WidgetStore.instance.getApps(this.props.room.roomId); if (this.state.editWidget && widgets.includes(this.state.editWidget)) { - this.setState({editWidget: null}); + this.setState({ editWidget: null }); } else { this.props.onBack(); } @@ -844,9 +881,9 @@ class WidgetExplorer extends React.Component ev.getId() === editWidget.eventId); if (!stateEv) { // "should never happen" return
    - {_t("There was an error finding this widget.")} + { _t("There was an error finding this widget.") }
    - +
    ; } @@ -865,17 +902,17 @@ class WidgetExplorer extends React.Component
    - {widgets.map(w => { + { widgets.map(w => { return ; - })} + >{ w.url }; + }) }
    - +
    ); } @@ -908,22 +945,22 @@ class SettingsExplorer extends React.PureComponent) => { - this.setState({query: ev.target.value}); + this.setState({ query: ev.target.value }); }; private onExplValuesEdit = (ev: ChangeEvent) => { - this.setState({explicitValues: ev.target.value}); + this.setState({ explicitValues: ev.target.value }); }; private onExplRoomValuesEdit = (ev: ChangeEvent) => { - this.setState({explicitRoomValues: ev.target.value}); + this.setState({ explicitRoomValues: ev.target.value }); }; private onBack = () => { if (this.state.editSetting) { - this.setState({editSetting: null}); + this.setState({ editSetting: null }); } else if (this.state.viewSetting) { - this.setState({viewSetting: null}); + this.setState({ viewSetting: null }); } else { this.props.onBack(); } @@ -931,7 +968,7 @@ class SettingsExplorer extends React.PureComponent { ev.preventDefault(); - this.setState({viewSetting: settingId}); + this.setState({ viewSetting: settingId }); }; private onEditClick = (ev: MouseEvent, settingId: string) => { @@ -949,7 +986,7 @@ class SettingsExplorer extends React.PureComponent{canEdit.toString()}; + return
    ; } render() { @@ -1021,46 +1058,53 @@ class SettingsExplorer extends React.PureComponent
    {_t( + { _t( customVariables[row[0]].expl, customVariables[row[0]].getTextVariables ? customVariables[row[0]].getTextVariables() : null, - )}{ row[1] }
    { canEdit.toString() }
    - - - + + + - {allSettings.map(i => ( + { allSettings.map(i => ( - ))} + )) }
    {_t("Setting ID")}{_t("Value")}{_t("Value in this room")}{ _t("Setting ID") }{ _t("Value") }{ _t("Value in this room") }
    this.onViewClick(e, i)}> - {i} + { i } - this.onEditClick(e, i)} + this.onEditClick(e, i)} className='mx_DevTools_SettingsExplorer_edit' > ✏ - {this.renderSettingValue(SettingsStore.getValue(i))} + { this.renderSettingValue(SettingsStore.getValue(i)) } - {this.renderSettingValue(SettingsStore.getValue(i, room.roomId))} + { this.renderSettingValue(SettingsStore.getValue(i, room.roomId)) }
    - +
    ); @@ -1068,62 +1112,70 @@ class SettingsExplorer extends React.PureComponent
    -

    {_t("Setting:")} {this.state.editSetting}

    +

    { _t("Setting:") } { this.state.editSetting }

    - {_t("Caution:")} {_t( + { _t("Caution:") } { _t( "This UI does NOT check the types of the values. Use at your own risk.", - )} + ) }
    - {_t("Setting definition:")} -
    {JSON.stringify(SETTINGS[this.state.editSetting], null, 4)}
    + { _t("Setting definition:") } +
    { JSON.stringify(SETTINGS[this.state.editSetting], null, 4) }
    - - - + + + - {LEVEL_ORDER.map(lvl => ( + { LEVEL_ORDER.map(lvl => ( - - {this.renderCanEditLevel(null, lvl)} - {this.renderCanEditLevel(room.roomId, lvl)} + + { this.renderCanEditLevel(null, lvl) } + { this.renderCanEditLevel(room.roomId, lvl) } - ))} + )) }
    {_t("Level")}{_t("Settable at global")}{_t("Settable at room")}{ _t("Level") }{ _t("Settable at global") }{ _t("Settable at room") }
    {lvl}{ lvl }
    - - + +
    ); @@ -1131,39 +1183,39 @@ class SettingsExplorer extends React.PureComponent
    -

    {_t("Setting:")} {this.state.viewSetting}

    +

    { _t("Setting:") } { this.state.viewSetting }

    - {_t("Setting definition:")} -
    {JSON.stringify(SETTINGS[this.state.viewSetting], null, 4)}
    + { _t("Setting definition:") } +
    { JSON.stringify(SETTINGS[this.state.viewSetting], null, 4) }
    - {_t("Value:")}  - {this.renderSettingValue( + { _t("Value:") }  + { this.renderSettingValue( SettingsStore.getValue(this.state.viewSetting), - )} + ) }
    - {_t("Value in this room:")}  - {this.renderSettingValue( + { _t("Value in this room:") }  + { this.renderSettingValue( SettingsStore.getValue(this.state.viewSetting, room.roomId), - )} + ) }
    - {_t("Values at explicit levels:")} -
    {this.renderExplicitSettingValues(
    +                            { _t("Values at explicit levels:") }
    +                            
    { this.renderExplicitSettingValues(
                                     this.state.viewSetting, null,
    -                            )}
    + ) }
    - {_t("Values at explicit levels in this room:")} -
    {this.renderExplicitSettingValues(
    +                            { _t("Values at explicit levels in this room:") }
    +                            
    { this.renderExplicitSettingValues(
                                     this.state.viewSetting, room.roomId,
    -                            )}
    + ) }
    @@ -1171,7 +1223,7 @@ class SettingsExplorer extends React.PureComponent this.onEditClick(e, this.state.viewSetting)}>{ _t("Edit Values") } - + ); @@ -1221,23 +1273,23 @@ export default class DevtoolsDialog extends React.PureComponent private onBack = () => { this.setState({ mode: null }); - } + }; private onCancel = () => { this.props.onFinished(false); - } + }; render() { let body; if (this.state.mode) { body = - {(cli) => + { (cli) =>
    { this.state.mode.getLabel() }
    Room ID: { this.props.roomId }
    - } + } ; } else { const classes = "mx_DevTools_RoomStateExplorer_button"; @@ -1261,7 +1313,6 @@ export default class DevtoolsDialog extends React.PureComponent ; } - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( { body } diff --git a/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx b/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx index ee3696b427..a0e6046d71 100644 --- a/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx +++ b/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx @@ -23,8 +23,8 @@ import AccessibleButton from "../elements/AccessibleButton"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore"; import FlairStore from "../../../stores/FlairStore"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {mediaFromMxc} from "../../../customisations/Media"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../../customisations/Media"; interface IProps extends IDialogProps { communityId: string; @@ -60,7 +60,7 @@ export default class EditCommunityPrototypeDialog extends React.PureComponent) => { - this.setState({name: ev.target.value}); + this.setState({ name: ev.target.value }); }; private onSubmit = async (ev) => { @@ -71,7 +71,7 @@ export default class EditCommunityPrototypeDialog extends React.PureComponent) => { if (!e.target.files || !e.target.files.length) { - this.setState({avatarFile: null}); + this.setState({ avatarFile: null }); } else { - this.setState({busy: true}); + this.setState({ busy: true }); const file = e.target.files[0]; const reader = new FileReader(); reader.onload = (ev: ProgressEvent) => { - this.setState({avatarFile: file, busy: false, avatarPreview: ev.target.result as string}); + this.setState({ avatarFile: file, busy: false, avatarPreview: ev.target.result as string }); }; reader.readAsDataURL(file); } @@ -122,7 +122,7 @@ export default class EditCommunityPrototypeDialog extends React.PureComponent; } else { - preview =
    + preview =
    ; } } @@ -144,23 +144,25 @@ export default class EditCommunityPrototypeDialog extends React.PureComponent
    {preview} + >{ preview }
    - {_t("Add image (optional)")} + { _t("Add image (optional)") } - {_t("An image will help people identify your community.")} + { _t("An image will help people identify your community.") }
    - {_t("Save")} + { _t("Save") }
    diff --git a/src/components/views/dialogs/ErrorDialog.js b/src/components/views/dialogs/ErrorDialog.tsx similarity index 73% rename from src/components/views/dialogs/ErrorDialog.js rename to src/components/views/dialogs/ErrorDialog.tsx index 5197c68b5a..56cd76237f 100644 --- a/src/components/views/dialogs/ErrorDialog.js +++ b/src/components/views/dialogs/ErrorDialog.tsx @@ -26,38 +26,37 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseDialog from "./BaseDialog"; + +interface IProps { + onFinished: (success: boolean) => void; + title?: string; + description?: React.ReactNode; + button?: string; + focus?: boolean; + headerImage?: string; +} + +interface IState { + onFinished: (success: boolean) => void; +} @replaceableComponent("views.dialogs.ErrorDialog") -export default class ErrorDialog extends React.Component { - static propTypes = { - title: PropTypes.string, - description: PropTypes.oneOfType([ - PropTypes.element, - PropTypes.string, - ]), - button: PropTypes.string, - focus: PropTypes.bool, - onFinished: PropTypes.func.isRequired, - headerImage: PropTypes.string, - }; - - static defaultProps = { +export default class ErrorDialog extends React.Component { + public static defaultProps = { focus: true, title: null, description: null, button: null, }; - onClick = () => { + private onClick = () => { this.props.onFinished(true); }; - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + public render() { return ( = ({ room, onFinished }) => { + const [exportFormat, setExportFormat] = useState(ExportFormat.Html); + const [exportType, setExportType] = useState(ExportType.Timeline); + const [includeAttachments, setAttachments] = useState(false); + const [isExporting, setExporting] = useState(false); + const [numberOfMessages, setNumberOfMessages] = useState(100); + const [sizeLimit, setSizeLimit] = useState(8); + const sizeLimitRef = useRef(); + const messageCountRef = useRef(); + const [exportProgressText, setExportProgressText] = useState("Processing..."); + const [displayCancel, setCancelWarning] = useState(false); + const [exportCancelled, setExportCancelled] = useState(false); + const [exportSuccessful, setExportSuccessful] = useState(false); + const [exporter, setExporter] = useStateCallback( + null, + async (exporter: Exporter) => { + await exporter?.export().then(() => { + if (!exportCancelled) setExportSuccessful(true); + }); + }, + ); + + const startExport = async () => { + const exportOptions = { + numberOfMessages, + attachmentsIncluded: includeAttachments, + maxSize: sizeLimit * 1024 * 1024, + }; + switch (exportFormat) { + case ExportFormat.Html: + setExporter( + new HTMLExporter( + room, + ExportType[exportType], + exportOptions, + setExportProgressText, + ), + ); + break; + case ExportFormat.Json: + setExporter( + new JSONExporter( + room, + ExportType[exportType], + exportOptions, + setExportProgressText, + ), + ); + break; + case ExportFormat.PlainText: + setExporter( + new PlainTextExporter( + room, + ExportType[exportType], + exportOptions, + setExportProgressText, + ), + ); + break; + default: + console.error("Unknown export format"); + return; + } + }; + + const onExportClick = async () => { + const isValidSize = await sizeLimitRef.current.validate({ + focused: false, + }); + if (!isValidSize) { + sizeLimitRef.current.validate({ focused: true }); + return; + } + if (exportType === ExportType.LastNMessages) { + const isValidNumberOfMessages = + await messageCountRef.current.validate({ focused: false }); + if (!isValidNumberOfMessages) { + messageCountRef.current.validate({ focused: true }); + return; + } + } + setExporting(true); + await startExport(); + }; + + const validateSize = withValidation({ + rules: [ + { + key: "required", + test({ value, allowEmpty }) { + return allowEmpty || !!value; + }, + invalid: () => { + const min = 1; + const max = 10 ** 8; + return _t("Enter a number between %(min)s and %(max)s", { + min, + max, + }); + }, + }, { + key: "number", + test: ({ value }) => { + const parsedSize = parseFloat(value); + const min = 1; + const max = 2000; + return !(isNaN(parsedSize) || min > parsedSize || parsedSize > max); + }, + invalid: () => { + const min = 1; + const max = 2000; + return _t( + "Size can only be a number between %(min)s MB and %(max)s MB", + { min, max }, + ); + }, + }, + ], + }); + + const onValidateSize = async (fieldState: IFieldState): Promise => { + const result = await validateSize(fieldState); + return result; + }; + + const validateNumberOfMessages = withValidation({ + rules: [ + { + key: "required", + test({ value, allowEmpty }) { + return allowEmpty || !!value; + }, + invalid: () => { + const min = 1; + const max = 10 ** 8; + return _t("Enter a number between %(min)s and %(max)s", { + min, + max, + }); + }, + }, { + key: "number", + test: ({ value }) => { + const parsedSize = parseFloat(value); + const min = 1; + const max = 10 ** 8; + if (isNaN(parsedSize)) return false; + return !(min > parsedSize || parsedSize > max); + }, + invalid: () => { + const min = 1; + const max = 10 ** 8; + return _t( + "Number of messages can only be a number between %(min)s and %(max)s", + { min, max }, + ); + }, + }, + ], + }); + + const onValidateNumberOfMessages = async (fieldState: IFieldState): Promise => { + const result = await validateNumberOfMessages(fieldState); + return result; + }; + + const onCancel = async () => { + if (isExporting) setCancelWarning(true); + else onFinished(false); + }; + + const confirmCanel = async () => { + await exporter?.cancelExport(); + setExportCancelled(true); + setExporting(false); + setExporter(null); + }; + + const exportFormatOptions = Object.keys(ExportFormat).map((format) => ({ + value: ExportFormat[format], + label: textForFormat(ExportFormat[format]), + })); + + const exportTypeOptions = Object.keys(ExportType).map((type) => { + return ( + + ); + }); + + let messageCount = null; + if (exportType === ExportType.LastNMessages) { + messageCount = ( + { + setNumberOfMessages(parseInt(e.target.value)); + }} + /> + ); + } + + const sizePostFix = { _t("MB") }; + + if (exportCancelled) { + // Display successful cancellation message + return ( + + ); + } else if (exportSuccessful) { + // Display successful export message + return ( + + ); + } else if (displayCancel) { + // Display cancel warning + return ( + +

    + { _t( + "Are you sure you want to stop exporting your data? If you do, you'll need to start over.", + ) } +

    + setCancelWarning(false)} + onPrimaryButtonClick={confirmCanel} + /> +
    + ); + } else { + // Display export settings + return ( + + { !isExporting ?

    + { _t( + "Select from the options below to export chats from your timeline", + ) } +

    : null } + + + { _t("Format") } + + +
    + setExportFormat(ExportFormat[key])} + definitions={exportFormatOptions} + /> + + + { _t("Messages") } + + + { + setExportType(ExportType[e.target.value]); + }} + > + { exportTypeOptions } + + { messageCount } + + + { _t("Size Limit") } + + + setSizeLimit(parseInt(e.target.value))} + /> + + + setAttachments( + (e.target as HTMLInputElement).checked, + ) + } + > + { _t("Include Attachments") } + +
    + { isExporting ? ( +
    + +

    + { exportProgressText } +

    + +
    + ) : ( + onFinished(false)} + /> + ) } +
    + ); + } +}; + +export default ExportDialog; diff --git a/src/components/views/dialogs/FeedbackDialog.js b/src/components/views/dialogs/FeedbackDialog.tsx similarity index 78% rename from src/components/views/dialogs/FeedbackDialog.js rename to src/components/views/dialogs/FeedbackDialog.tsx index d80a935573..e7089283e4 100644 --- a/src/components/views/dialogs/FeedbackDialog.js +++ b/src/components/views/dialogs/FeedbackDialog.tsx @@ -14,36 +14,38 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useState} from 'react'; +import React, { useState } from 'react'; import QuestionDialog from './QuestionDialog'; import { _t } from '../../../languageHandler'; import Field from "../elements/Field"; import AccessibleButton from "../elements/AccessibleButton"; -import CountlyAnalytics from "../../../CountlyAnalytics"; +import CountlyAnalytics, { Rating } from "../../../CountlyAnalytics"; import SdkConfig from "../../../SdkConfig"; import Modal from "../../../Modal"; import BugReportDialog from "./BugReportDialog"; import InfoDialog from "./InfoDialog"; import StyledRadioGroup from "../elements/StyledRadioGroup"; +import { IDialogProps } from "./IDialogProps"; const existingIssuesUrl = "https://github.com/vector-im/element-web/issues" + "?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc"; -const newIssueUrl = "https://github.com/vector-im/element-web/issues/new"; +const newIssueUrl = "https://github.com/vector-im/element-web/issues/new/choose"; +interface IProps extends IDialogProps {} -export default (props) => { - const [rating, setRating] = useState(""); - const [comment, setComment] = useState(""); +const FeedbackDialog: React.FC = (props: IProps) => { + const [rating, setRating] = useState(); + const [comment, setComment] = useState(""); - const onDebugLogsLinkClick = () => { + const onDebugLogsLinkClick = (): void => { props.onFinished(); Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {}); }; const hasFeedback = CountlyAnalytics.instance.canEnable(); - const onFinished = (sendFeedback) => { + const onFinished = (sendFeedback: boolean): void => { if (hasFeedback && sendFeedback) { - CountlyAnalytics.instance.reportFeedback(parseInt(rating, 10), comment); + CountlyAnalytics.instance.reportFeedback(rating, comment); Modal.createTrackedDialog('Feedback sent', '', InfoDialog, { title: _t('Feedback sent'), description: _t('Thank you!'), @@ -59,15 +61,15 @@ export default (props) => { countlyFeedbackSection =
    -

    {_t("Rate %(brand)s", { brand })}

    +

    { _t("Rate %(brand)s", { brand }) }

    -

    {_t("Tell us below how you feel about %(brand)s so far.", { brand })}

    -

    {_t("Please go into as much detail as you like, so we can track down the problem.")}

    +

    { _t("Tell us below how you feel about %(brand)s so far.", { brand }) }

    +

    { _t("Please go into as much detail as you like, so we can track down the problem.") }

    setRating(parseInt(r, 10) as Rating)} definitions={[ { value: "1", label: "😠" }, { value: "2", label: "😞" }, @@ -96,7 +98,7 @@ export default (props) => { let subheading; if (hasFeedback) { subheading = ( -

    {_t("There are two ways you can provide feedback and help us improve %(brand)s.", { brand })}

    +

    { _t("There are two ways you can provide feedback and help us improve %(brand)s.", { brand }) }

    ); } @@ -107,7 +109,7 @@ export default (props) => { _t("PRO TIP: If you start a bug, please submit debug logs " + "to help us track down the problem.", {}, { debugLogsLink: sub => ( - {sub} + { sub } ), }) }

    @@ -122,7 +124,7 @@ export default (props) => { { subheading }
    -

    {_t("Report a bug")}

    +

    { _t("Report a bug") }

    { _t("Please view existing bugs on Github first. " + "No match? Start a new one.", {}, { @@ -134,12 +136,14 @@ export default (props) => { }, }) }

    - {bugReports} + { bugReports }
    { countlyFeedbackSection } } button={hasFeedback ? _t("Send feedback") : _t("Go back")} - buttonDisabled={hasFeedback && rating === ""} + buttonDisabled={hasFeedback && !rating} onFinished={onFinished} />); }; + +export default FeedbackDialog; diff --git a/src/components/views/dialogs/ForwardDialog.tsx b/src/components/views/dialogs/ForwardDialog.tsx new file mode 100644 index 0000000000..7f08a3eb58 --- /dev/null +++ b/src/components/views/dialogs/ForwardDialog.tsx @@ -0,0 +1,274 @@ +/* +Copyright 2021 Robin Townsend + +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, { useMemo, useState, useEffect } from "react"; +import classnames from "classnames"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; + +import { _t } from "../../../languageHandler"; +import dis from "../../../dispatcher/dispatcher"; +import { useSettingValue, useFeatureEnabled } from "../../../hooks/useSettings"; +import { UIFeature } from "../../../settings/UIFeature"; +import { Layout } from "../../../settings/Layout"; +import { IDialogProps } from "./IDialogProps"; +import BaseDialog from "./BaseDialog"; +import { avatarUrlForUser } from "../../../Avatar"; +import EventTile from "../rooms/EventTile"; +import SearchBox from "../../structures/SearchBox"; +import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; +import { Alignment } from '../elements/Tooltip'; +import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; +import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; +import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; +import NotificationBadge from "../rooms/NotificationBadge"; +import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; +import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; +import QueryMatcher from "../../../autocomplete/QueryMatcher"; +import TruncatedList from "../elements/TruncatedList"; +import EntityTile from "../rooms/EntityTile"; +import BaseAvatar from "../avatars/BaseAvatar"; +import SpaceStore from "../../../stores/SpaceStore"; + +const AVATAR_SIZE = 30; + +interface IProps extends IDialogProps { + matrixClient: MatrixClient; + // The event to forward + event: MatrixEvent; + // We need a permalink creator for the source room to pass through to EventTile + // in case the event is a reply (even though the user can't get at the link) + permalinkCreator: RoomPermalinkCreator; +} + +interface IEntryProps { + room: Room; + event: MatrixEvent; + matrixClient: MatrixClient; + onFinished(success: boolean): void; +} + +enum SendState { + CanSend, + Sending, + Sent, + Failed, +} + +const Entry: React.FC = ({ room, event, matrixClient: cli, onFinished }) => { + const [sendState, setSendState] = useState(SendState.CanSend); + + const jumpToRoom = () => { + dis.dispatch({ + action: "view_room", + room_id: room.roomId, + }); + onFinished(true); + }; + const send = async () => { + setSendState(SendState.Sending); + try { + await cli.sendEvent(room.roomId, event.getType(), event.getContent()); + setSendState(SendState.Sent); + } catch (e) { + setSendState(SendState.Failed); + } + }; + + let className; + let disabled = false; + let title; + let icon; + if (sendState === SendState.CanSend) { + className = "mx_ForwardList_canSend"; + if (room.maySendMessage()) { + title = _t("Send"); + } else { + disabled = true; + title = _t("You don't have permission to do this"); + } + } else if (sendState === SendState.Sending) { + className = "mx_ForwardList_sending"; + disabled = true; + title = _t("Sending"); + icon =
    ; + } else if (sendState === SendState.Sent) { + className = "mx_ForwardList_sent"; + disabled = true; + title = _t("Sent"); + icon =
    ; + } else { + className = "mx_ForwardList_sendFailed"; + disabled = true; + title = _t("Failed to send"); + icon = ; + } + + return
    + + + { room.name } + + +
    { _t("Send") }
    + { icon } +
    +
    ; +}; + +const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCreator, onFinished }) => { + const userId = cli.getUserId(); + const [profileInfo, setProfileInfo] = useState({}); + useEffect(() => { + cli.getProfileInfo(userId).then(info => setProfileInfo(info)); + }, [cli, userId]); + + // For the message preview we fake the sender as ourselves + const mockEvent = new MatrixEvent({ + type: "m.room.message", + sender: userId, + content: event.getContent(), + unsigned: { + age: 97, + }, + event_id: "$9999999999999999999999999999999999999999999", + room_id: event.getRoomId(), + }); + mockEvent.sender = { + name: profileInfo.displayname || userId, + rawDisplayName: profileInfo.displayname, + userId, + getAvatarUrl: (..._) => { + return avatarUrlForUser( + { avatarUrl: profileInfo.avatar_url }, + AVATAR_SIZE, AVATAR_SIZE, "crop", + ); + }, + getMxcAvatarUrl: () => profileInfo.avatar_url, + } as RoomMember; + + const [query, setQuery] = useState(""); + const lcQuery = query.toLowerCase(); + + const spacesEnabled = SpaceStore.spacesEnabled; + const flairEnabled = useFeatureEnabled(UIFeature.Flair); + const previewLayout = useSettingValue("layout"); + + let rooms = useMemo(() => sortRooms( + cli.getVisibleRooms().filter( + room => room.getMyMembership() === "join" && + !(spacesEnabled && room.isSpaceRoom()), + ), + ), [cli, spacesEnabled]); + + if (lcQuery) { + rooms = new QueryMatcher(rooms, { + keys: ["name"], + funcs: [r => [r.getCanonicalAlias(), ...r.getAltAliases()].filter(Boolean)], + shouldMatchWordsOnly: false, + }).match(lcQuery); + } + + const [truncateAt, setTruncateAt] = useState(20); + function overflowTile(overflowCount, totalCount) { + const text = _t("and %(count)s others...", { count: overflowCount }); + return ( + + } + name={text} + presenceState="online" + suppressOnHover={true} + onClick={() => setTruncateAt(totalCount)} + /> + ); + } + + return +

    { _t("Message preview") }

    +
    + +
    +
    +
    + + + { rooms.length > 0 ? ( +
    + rooms.slice(start, end).map(room => + , + )} + getChildCount={() => rooms.length} + /> +
    + ) : + { _t("No results") } + } +
    +
    +
    ; +}; + +export default ForwardDialog; diff --git a/src/components/views/dialogs/GenericFeatureFeedbackDialog.tsx b/src/components/views/dialogs/GenericFeatureFeedbackDialog.tsx new file mode 100644 index 0000000000..d68569b126 --- /dev/null +++ b/src/components/views/dialogs/GenericFeatureFeedbackDialog.tsx @@ -0,0 +1,101 @@ +/* +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, { useState } from "react"; + +import QuestionDialog from './QuestionDialog'; +import { _t } from '../../../languageHandler'; +import Field from "../elements/Field"; +import SdkConfig from "../../../SdkConfig"; +import { IDialogProps } from "./IDialogProps"; +import { submitFeedback } from "../../../rageshake/submit-rageshake"; +import StyledCheckbox from "../elements/StyledCheckbox"; +import Modal from "../../../Modal"; +import InfoDialog from "./InfoDialog"; + +interface IProps extends IDialogProps { + title: string; + subheading: string; + rageshakeLabel: string; + rageshakeData?: Record; +} + +const GenericFeatureFeedbackDialog: React.FC = ({ + title, + subheading, + children, + rageshakeLabel, + rageshakeData = {}, + onFinished, +}) => { + const [comment, setComment] = useState(""); + const [canContact, setCanContact] = useState(false); + + const sendFeedback = async (ok: boolean) => { + if (!ok) return onFinished(false); + + submitFeedback(SdkConfig.get().bug_report_endpoint_url, rageshakeLabel, comment, canContact, rageshakeData); + onFinished(true); + + Modal.createTrackedDialog("Feedback Sent", rageshakeLabel, InfoDialog, { + title, + description: _t("Thank you for your feedback, we really appreciate it."), + button: _t("Done"), + hasCloseButton: false, + fixedWidth: false, + }); + }; + + return ( +
    + { subheading } +   + { _t("Your platform and username will be noted to help us use your feedback as much as we can.") } + + { children } +
    + + { + setComment(ev.target.value); + }} + autoFocus={true} + /> + + setCanContact((e.target as HTMLInputElement).checked)} + > + { _t("You may contact me if you have any follow up questions") } + + } + button={_t("Send feedback")} + buttonDisabled={!comment} + onFinished={sendFeedback} + />); +}; + +export default GenericFeatureFeedbackDialog; diff --git a/src/components/views/dialogs/HostSignupDialog.tsx b/src/components/views/dialogs/HostSignupDialog.tsx index c8bc907136..4b8b7f32f0 100644 --- a/src/components/views/dialogs/HostSignupDialog.tsx +++ b/src/components/views/dialogs/HostSignupDialog.tsx @@ -31,7 +31,7 @@ import { IPostmessageResponseData, PostmessageAction, } from "./HostSignupDialogTypes"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; const HOST_SIGNUP_KEY = "host_signup"; @@ -86,7 +86,7 @@ export default class HostSignupDialog extends React.PureComponent { this.setState({ @@ -96,7 +96,7 @@ export default class HostSignupDialog extends React.PureComponent { this.setState({ @@ -106,7 +106,7 @@ export default class HostSignupDialog extends React.PureComponent { window.removeEventListener("message", this.messageHandler); @@ -114,7 +114,7 @@ export default class HostSignupDialog extends React.PureComponent { if (this.state.completed) { @@ -137,16 +137,16 @@ export default class HostSignupDialog extends React.PureComponent { this.iframeRef.current.contentWindow.postMessage(message, this.config.url); - } + }; private async sendAccountDetails() { const openIdToken = await MatrixClientPeg.get().getOpenIdToken(); if (!openIdToken || !openIdToken.access_token) { - console.warn("Failed to connect to homeserver for OpenID token.") + console.warn("Failed to connect to homeserver for OpenID token."); this.setState({ completed: true, error: _t("Failed to connect to your homeserver. Please close this dialog and try again."), @@ -171,38 +171,38 @@ export default class HostSignupDialog extends React.PureComponent { const textComponent = ( <>

    - {_t("Continuing temporarily allows the %(hostSignupBrand)s setup process to access your " + + { _t("Continuing temporarily allows the %(hostSignupBrand)s setup process to access your " + "account to fetch verified email addresses. This data is not stored.", { hostSignupBrand: this.config.brand, - })} + }) }

    - {_t("Learn more in our , and .", + { _t("Learn more in our , and .", {}, { cookiePolicyLink: () => ( - {_t("Cookie Policy")} + { _t("Cookie Policy") } ), privacyPolicyLink: () => ( - {_t("Privacy Policy")} + { _t("Privacy Policy") } ), termsOfServiceLink: () => ( - {_t("Terms of Service")} + { _t("Terms of Service") } ), }, - )} + ) }

    ); @@ -215,7 +215,7 @@ export default class HostSignupDialog extends React.PureComponent - {this.state.minimized && + { this.state.minimized &&
    - {_t("%(hostSignupBrand)s Setup", { + { _t("%(hostSignupBrand)s Setup", { hostSignupBrand: this.config.brand, - })} + }) }
    } - {!this.state.minimized && + { !this.state.minimized &&
    } - {this.state.error && + { this.state.error &&
    - {this.state.error} + { this.state.error }
    } - {!this.state.error && + { !this.state.error &&