diff --git a/.editorconfig b/.editorconfig index 880331a09e5..56631484cd5 100644 --- a/.editorconfig +++ b/.editorconfig @@ -21,3 +21,6 @@ insert_final_newline = true indent_style = space indent_size = 4 trim_trailing_whitespace = true + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.eslintrc.js b/.eslintrc.js index 67e6ab1e642..f3e38f94c3c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -86,6 +86,8 @@ module.exports = { "jsx-a11y/no-static-element-interactions": "off", "jsx-a11y/role-supports-aria-props": "off", "jsx-a11y/tabindex-no-positive": "off", + + "matrix-org/require-copyright-header": "error", }, overrides: [ { diff --git a/.github/codecov.yml b/.github/codecov.yml deleted file mode 100644 index 449fa0a733a..00000000000 --- a/.github/codecov.yml +++ /dev/null @@ -1,12 +0,0 @@ -codecov: - allow_coverage_offsets: True -coverage: - status: - project: off - patch: off -comment: - layout: "diff, files" - behavior: default - require_changes: false - require_base: no - require_head: no diff --git a/.github/workflows/element-build-and-test.yaml b/.github/workflows/element-build-and-test.yaml index 1633aae2609..9b3d0f373a0 100644 --- a/.github/workflows/element-build-and-test.yaml +++ b/.github/workflows/element-build-and-test.yaml @@ -3,47 +3,100 @@ # as an artifact and run integration tests. name: Element Web - Build and Test on: - pull_request: + pull_request: { } + push: + branches: [ develop, master ] + repository_dispatch: + types: [ upstream-sdk-notify ] +env: + # These must be set for fetchdep.sh to get the right branch + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} jobs: - build: - runs-on: ubuntu-latest + build: + name: "Build Element-Web" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-node@v3 + with: + cache: 'yarn' + + - name: Fetch layered build + id: layered_build + run: | + scripts/ci/layered.sh + JSSDK_SHA=$(git -C matrix-js-sdk rev-parse --short=12 HEAD) + REACT_SHA=$(git rev-parse --short=12 HEAD) + VECTOR_SHA=$(git -C element-web rev-parse --short=12 HEAD) + echo "::set-output name=VERSION::$VECTOR_SHA-react-$REACT_SHA-js-$JSSDK_SHA" + + - name: Copy config + run: cp element.io/develop/config.json config.json + working-directory: ./element-web + + - name: Build env: - # This must be set for fetchdep.sh to get the right branch - 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 - cypress: - needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Download build - uses: actions/download-artifact@v3 - with: - name: previewbuild - path: webapp - - name: Run Cypress tests - uses: cypress-io/github-action@v2 - with: - # The built in Electron runner seems to grind to a halt trying - # to run the tests, so use chrome. - browser: chrome - start: npx serve -p 8080 webapp - - name: Upload Artifact - if: failure() - uses: actions/upload-artifact@v2 - with: - name: cypress-results - path: | - cypress/screenshots - cypress/videos - cypress/synapselogs + CI_PACKAGE: true + VERSION: "${{ steps.layered_build.outputs.VERSION }}" + run: yarn build + working-directory: ./element-web + + - 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 + + cypress: + name: "Cypress End to End Tests" + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Download build + uses: actions/download-artifact@v3 + with: + name: previewbuild + path: webapp + + - name: Run Cypress tests + uses: cypress-io/github-action@v2 + with: + # The built in Electron runner seems to grind to a halt trying + # to run the tests, so use chrome. + browser: chrome + start: npx serve -p 8080 webapp + record: true + env: + # pass the Dashboard record key as an environment variable + CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} + # pass GitHub token to allow accurately detecting a build vs a re-run build + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Artifact + if: failure() + uses: actions/upload-artifact@v2 + with: + name: cypress-results + path: | + cypress/screenshots + cypress/videos + cypress/synapselogs + + app-tests: + name: Element Web Integration Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-node@v3 + with: + cache: 'yarn' + + - name: Run tests + run: "./scripts/ci/app-tests.sh" diff --git a/.github/workflows/end-to-end-tests.yaml b/.github/workflows/end-to-end-tests.yaml index 334af1772fd..6c663a0e018 100644 --- a/.github/workflows/end-to-end-tests.yaml +++ b/.github/workflows/end-to-end-tests.yaml @@ -1,47 +1,59 @@ name: End-to-end Tests 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] + # 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 ] + repository_dispatch: + types: [ upstream-sdk-notify ] +env: + # These must be set for fetchdep.sh to get the right branch + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} jobs: - end-to-end: - runs-on: ubuntu-latest - env: - # This must be set for fetchdep.sh to get the right branch - 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' }} + end-to-end: + runs-on: ubuntu-latest + container: vectorim/element-web-ci-e2etests-env:latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - uses: actions/setup-node@v3 + with: + cache: 'yarn' + + - 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/netlify.yaml b/.github/workflows/netlify.yaml index ec09379b6e3..bada40e077e 100644 --- a/.github/workflows/netlify.yaml +++ b/.github/workflows/netlify.yaml @@ -2,76 +2,94 @@ # and uploading it to netlify name: Upload Preview Build to Netlify on: - workflow_run: - workflows: ["Element Web - Build and Test"] - types: - - completed + workflow_run: + workflows: [ "Element Web - Build and Test" ] + types: + - completed jobs: - build: - runs-on: ubuntu-latest - if: > - ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' }} - steps: - - name: "🔍 Read PR number" - id: readctx - # we need to find the PR number that corresponds to the branch, which we do by - # searching the GH API - # The workflow_run event includes a list of pull requests, but it doesn't get populated for - # forked PRs: https://docs.github.com/en/rest/reference/checks#create-a-check-run - run: | - head_branch='${{github.event.workflow_run.head_repository.owner.login}}:${{github.event.workflow_run.head_branch}}' - echo "head branch: $head_branch" - pulls_uri="https://api.github.com/repos/${{ github.repository }}/pulls?head=$(jq -Rr '@uri' <<<$head_branch)" - pr_number=$(curl -s -H 'Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}' "$pulls_uri" | - jq -r '.[] | .number') - echo "PR number: $pr_number" - echo "::set-output name=prnumber::$pr_number" - # 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)); - - name: Extract Artifacts - run: unzip -d webapp previewbuild.zip && rm previewbuild.zip - - 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 - alias: pr${{ steps.readctx.outputs.prnumber }} - env: - NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} - NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} - timeout-minutes: 1 - - name: Edit PR Description - uses: Beakyn/gha-comment-pull-request@2167a7aee24f9e61ce76a23039f322e49a990409 - 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. + deploy: + runs-on: ubuntu-latest + if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' + steps: + - name: "🔍 Read PR number" + id: readctx + # We need to find the PR number that corresponds to the branch, which we do by searching the GH API + # The workflow_run event includes a list of pull requests, but it doesn't get populated for + # forked PRs: https://docs.github.com/en/rest/reference/checks#create-a-check-run + run: | + head_branch='${{github.event.workflow_run.head_repository.owner.login}}:${{github.event.workflow_run.head_branch}}' + echo "Head branch: $head_branch" + pulls_uri="https://api.github.com/repos/${{ github.repository }}/pulls?head=$(jq -Rr '@uri' <<<$head_branch)" + pr_number=$(curl -s -H 'Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}' "$pulls_uri" | + jq -r '.[] | .number') + echo "PR number: $pr_number" + echo "::set-output name=prnumber::$pr_number" + - name: Create Deployment + uses: bobheadxi/deployments@v1 + id: deployment + with: + step: start + token: ${{ secrets.GITHUB_TOKEN }} + env: Netlify + ref: ${{ github.event.workflow_run.head_sha }} + desc: | + Do you trust the author of this PR? Maybe this build will steal your keys or give you malware. + Exercise caution. Use test accounts. + + # 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: | + const artifacts = await github.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: ${{ github.event.workflow_run.id }}, + }); + const matchArtifact = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "previewbuild" + })[0]; + const download = await github.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: matchArtifact.id, + archive_format: 'zip', + }); + const fs = require('fs'); + fs.writeFileSync('${{github.workspace}}/previewbuild.zip', Buffer.from(download.data)); + + - name: Extract Artifacts + run: unzip -d webapp previewbuild.zip && rm previewbuild.zip + + - 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 + alias: pr${{ steps.readctx.outputs.prnumber }} + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + timeout-minutes: 1 + + - name: Update deployment status + uses: bobheadxi/deployments@v1 + if: always() + with: + step: finish + override: false + token: ${{ secrets.GITHUB_TOKEN }} + status: ${{ job.status }} + env: ${{ steps.deployment.outputs.env }} + deployment_id: ${{ steps.deployment.outputs.deployment_id }} + env_url: ${{ steps.netlify.outputs.deploy-url }} + desc: | + 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/notify-element-web.yml b/.github/workflows/notify-element-web.yml index ef463784f38..1d60a1523cc 100644 --- a/.github/workflows/notify-element-web.yml +++ b/.github/workflows/notify-element-web.yml @@ -1,15 +1,17 @@ name: Notify element-web on: - push: - branches: [develop] + push: + branches: [ develop ] + repository_dispatch: + types: [ upstream-sdk-notify ] jobs: - notify-element-web: - runs-on: ubuntu-latest - environment: develop - steps: - - name: Notify element-web repo that a new SDK build is on develop - uses: peter-evans/repository-dispatch@v1 - with: - token: ${{ secrets.ELEMENT_WEB_NOTIFY_TOKEN }} - repository: vector-im/element-web - event-type: element-web-notify + notify-element-web: + name: "Notify Element Web" + runs-on: ubuntu-latest + steps: + - name: Notify element-web repo that a new SDK build is on develop + uses: peter-evans/repository-dispatch@v1 + with: + token: ${{ secrets.ELEMENT_BOT_TOKEN }} + repository: vector-im/element-web + event-type: element-web-notify diff --git a/.github/workflows/preview_changelog.yaml b/.github/workflows/preview_changelog.yaml deleted file mode 100644 index d68d19361da..00000000000 --- a/.github/workflows/preview_changelog.yaml +++ /dev/null @@ -1,12 +0,0 @@ -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/pull_request.yaml b/.github/workflows/pull_request.yaml new file mode 100644 index 00000000000..22a92bf0b56 --- /dev/null +++ b/.github/workflows/pull_request.yaml @@ -0,0 +1,24 @@ +name: Pull Request +on: + pull_request_target: + types: [ opened, edited, labeled, unlabeled ] +jobs: + changelog: + name: Preview Changelog + runs-on: ubuntu-latest + steps: + - uses: matrix-org/allchange@main + with: + ghToken: ${{ secrets.GITHUB_TOKEN }} + + enforce-label: + name: Enforce Labels + runs-on: ubuntu-latest + permissions: + pull-requests: read + steps: + - uses: yogevbd/enforce-label-action@2.1.0 + with: + REQUIRED_LABELS_ANY: "T-Defect,T-Enhancement,T-Task" + BANNED_LABELS: "X-Blocked" + BANNED_LABELS_DESCRIPTION: "Preventing merge whilst PR is marked blocked!" diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml new file mode 100644 index 00000000000..7029be97f3b --- /dev/null +++ b/.github/workflows/sonarqube.yml @@ -0,0 +1,47 @@ +name: SonarQube +on: + workflow_run: + workflows: [ "Tests" ] + types: + - completed +jobs: + sonarqube: + name: SonarQube + runs-on: ubuntu-latest + if: github.event.workflow_run.conclusion == 'success' + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + + # 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 Coverage Report + uses: actions/github-script@v3.1.0 + with: + script: | + const artifacts = await github.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: ${{ github.event.workflow_run.id }}, + }); + const matchArtifact = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "coverage" + })[0]; + const download = await github.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: matchArtifact.id, + archive_format: 'zip', + }); + const fs = require('fs'); + fs.writeFileSync('${{github.workspace}}/coverage.zip', Buffer.from(download.data)); + + - name: Extract Coverage Report + run: unzip -d coverage coverage.zip && rm coverage.zip + + - name: SonarCloud Scan + uses: SonarSource/sonarcloud-github-action@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/static_analysis.yaml b/.github/workflows/static_analysis.yaml new file mode 100644 index 00000000000..266f7c728ad --- /dev/null +++ b/.github/workflows/static_analysis.yaml @@ -0,0 +1,110 @@ +name: Static Analysis +on: + pull_request: { } + push: + branches: [ develop, master ] + repository_dispatch: + types: [ upstream-sdk-notify ] +env: + # These must be set for fetchdep.sh to get the right branch + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} +jobs: + ts_lint: + name: "Typescript Syntax Check" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-node@v3 + with: + cache: 'yarn' + + - 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" + + i18n_lint: + name: "i18n Check" + runs-on: ubuntu-latest + permissions: + pull-requests: read + steps: + - uses: actions/checkout@v2 + + - name: "Get modified files" + id: changed_files + if: github.event_name == 'pull_request' && github.actor != 'RiotTranslateBot' + uses: tj-actions/changed-files@v19 + with: + files: | + src/i18n/strings/* + files_ignore: | + src/i18n/strings/en_EN.json + + - name: "Assert only en_EN was modified" + if: | + github.event_name == 'pull_request' && + github.actor != 'RiotTranslateBot' && + steps.changed_files.outputs.any_modified == 'true' + run: | + echo "You can only modify en_EN.json, do not touch any of the other i18n files as Weblate will be confused" + exit 1 + + - uses: actions/setup-node@v3 + with: + cache: 'yarn' + + # Does not need branch matching as only analyses this layer + - name: Install Deps + run: "yarn install" + + - name: i18n Check + run: "yarn run diff-i18n" + + js_lint: + name: "ESLint" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-node@v3 + with: + cache: 'yarn' + + # Does not need branch matching as only analyses this layer + - name: Install Deps + run: "yarn install" + + - name: Run Linter + run: "yarn run lint:js" + + style_lint: + name: "Style Lint" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-node@v3 + with: + cache: 'yarn' + + # Does not need branch matching as only analyses this layer + - name: Install Deps + run: "yarn install" + + - name: Run Linter + run: "yarn run lint:style" diff --git a/.github/workflows/test_coverage.yml b/.github/workflows/test_coverage.yml deleted file mode 100644 index 4cd9f6d2f06..00000000000 --- a/.github/workflows/test_coverage.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Test coverage -on: - pull_request: {} - push: - branches: [develop, main, master] -jobs: - test-coverage: - runs-on: ubuntu-latest - env: - # This must be set for fetchdep.sh to get the right branch - PR_NUMBER: ${{github.event.number}} - steps: - - name: Checkout code - uses: actions/checkout@v2 - with: - # If this is a pull request, make sure we check out its head rather than the - # automatically generated merge commit, so that the coverage diff excludes - # unrelated changes in the base branch - ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }} - - - name: Yarn cache - uses: c-hive/gha-yarn-cache@v2 - - - name: Install Deps - run: "./scripts/ci/install-deps.sh --ignore-scripts" - - - name: Run tests with coverage - run: "yarn install && yarn coverage" - - - name: Upload coverage - uses: codecov/codecov-action@v2 - with: - fail_ci_if_error: false - verbose: true - override_commit: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000000..9fa7a6f7cf1 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,37 @@ +name: Tests +on: + pull_request: { } + push: + branches: [ develop, master ] + repository_dispatch: + types: [ upstream-sdk-notify ] +env: + # These must be set for fetchdep.sh to get the right branch + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} +jobs: + jest: + name: Jest + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Yarn cache + uses: actions/setup-node@v3 + with: + cache: 'yarn' + + - name: Install Deps + run: "./scripts/ci/install-deps.sh --ignore-scripts" + + - name: Run tests with coverage + run: "yarn coverage --ci" + + - name: Upload Artifact + uses: actions/upload-artifact@v2 + with: + name: coverage + path: | + coverage + !coverage/lcov-report diff --git a/.github/workflows/typecheck.yaml b/.github/workflows/typecheck.yaml deleted file mode 100644 index 60cabb3caba..00000000000 --- a/.github/workflows/typecheck.yaml +++ /dev/null @@ -1,27 +0,0 @@ -name: Type Check -on: - pull_request: - branches: [develop] -jobs: - build: - runs-on: ubuntu-latest - env: - # This must be set for fetchdep.sh to get the right branch - 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/.github/workflows/upgrade_dependencies.yml b/.github/workflows/upgrade_dependencies.yml new file mode 100644 index 00000000000..a4a0fedc0d9 --- /dev/null +++ b/.github/workflows/upgrade_dependencies.yml @@ -0,0 +1,8 @@ +name: Upgrade Dependencies +on: + workflow_dispatch: { } +jobs: + upgrade: + uses: matrix-org/matrix-js-sdk/.github/workflows/upgrade_dependencies.yml@develop + secrets: + ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0126f1f369f..841a7360a10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,54 @@ +Changes in [3.43.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.43.0) (2022-04-26) +===================================================================================================== + +## ✨ Features + * Improve performance of switching to rooms with lots of servers and ACLs ([\#8347](https://github.com/matrix-org/matrix-react-sdk/pull/8347)). + * Avoid a reflow when setting caret position on an empty composer ([\#8348](https://github.com/matrix-org/matrix-react-sdk/pull/8348)). + * Add message right-click context menu as a labs feature ([\#5672](https://github.com/matrix-org/matrix-react-sdk/pull/5672)). + * Live location sharing - basic maximised beacon map ([\#8310](https://github.com/matrix-org/matrix-react-sdk/pull/8310)). + * Live location sharing - render users own beacons in timeline ([\#8296](https://github.com/matrix-org/matrix-react-sdk/pull/8296)). + * Improve Threads beta around degraded mode ([\#8318](https://github.com/matrix-org/matrix-react-sdk/pull/8318)). + * Live location sharing - beacon in timeline happy path ([\#8285](https://github.com/matrix-org/matrix-react-sdk/pull/8285)). + * Add copy button to View Source screen ([\#8278](https://github.com/matrix-org/matrix-react-sdk/pull/8278)). Fixes vector-im/element-web#21482. Contributed by @olivialivia. + * Add heart effect ([\#6188](https://github.com/matrix-org/matrix-react-sdk/pull/6188)). Contributed by @CicadaCinema. + * Update new room icon ([\#8239](https://github.com/matrix-org/matrix-react-sdk/pull/8239)). + +## 🐛 Bug Fixes + * Fix: "Code formatting button does not escape backticks" ([\#8181](https://github.com/matrix-org/matrix-react-sdk/pull/8181)). Contributed by @yaya-usman. + * Fix beta indicator dot causing excessive CPU usage ([\#8340](https://github.com/matrix-org/matrix-react-sdk/pull/8340)). Fixes vector-im/element-web#21793. + * Fix overlapping timestamps on empty messages ([\#8205](https://github.com/matrix-org/matrix-react-sdk/pull/8205)). Fixes vector-im/element-web#21381. Contributed by @goelesha. + * Fix power selector not showing up in user info when state_default undefined ([\#8297](https://github.com/matrix-org/matrix-react-sdk/pull/8297)). Fixes vector-im/element-web#21669. + * Avoid looking up settings during timeline rendering ([\#8313](https://github.com/matrix-org/matrix-react-sdk/pull/8313)). Fixes vector-im/element-web#21740. + * Fix a soft crash with video rooms ([\#8333](https://github.com/matrix-org/matrix-react-sdk/pull/8333)). + * Fixes call tiles overflow ([\#8096](https://github.com/matrix-org/matrix-react-sdk/pull/8096)). Fixes vector-im/element-web#20254. Contributed by @luixxiul. + * Fix a bug with emoji autocomplete sorting where adding the final ":" would cause the emoji with the typed shortcode to no longer be at the top of the autocomplete list. ([\#8086](https://github.com/matrix-org/matrix-react-sdk/pull/8086)). Fixes vector-im/element-web#19302. Contributed by @commonlawfeature. + * Fix image preview sizing for edge cases ([\#8322](https://github.com/matrix-org/matrix-react-sdk/pull/8322)). Fixes vector-im/element-web#20088. + * Refactor SecurityRoomSettingsTab and remove unused state ([\#8306](https://github.com/matrix-org/matrix-react-sdk/pull/8306)). Fixes matrix-org/element-web-rageshakes#12002. + * Don't show the prompt to enable desktop notifications immediately after registration ([\#8274](https://github.com/matrix-org/matrix-react-sdk/pull/8274)). + * Stop tracking threads if threads support is disabled ([\#8308](https://github.com/matrix-org/matrix-react-sdk/pull/8308)). Fixes vector-im/element-web#21766. + * Fix some issues with threads rendering ([\#8305](https://github.com/matrix-org/matrix-react-sdk/pull/8305)). Fixes vector-im/element-web#21670. + * Fix threads rendering issue in Safari ([\#8298](https://github.com/matrix-org/matrix-react-sdk/pull/8298)). Fixes vector-im/element-web#21757. + * Fix space panel width change on hovering over space item ([\#8299](https://github.com/matrix-org/matrix-react-sdk/pull/8299)). Fixes vector-im/element-web#19891. + * Hide the reply in thread button in deployments where beta is forcibly disabled ([\#8294](https://github.com/matrix-org/matrix-react-sdk/pull/8294)). Fixes vector-im/element-web#21753. + * Prevent soft crash around room list header context menu when space changes ([\#8289](https://github.com/matrix-org/matrix-react-sdk/pull/8289)). Fixes matrix-org/element-web-rageshakes#11416, matrix-org/element-web-rageshakes#11692, matrix-org/element-web-rageshakes#11739, matrix-org/element-web-rageshakes#11772, matrix-org/element-web-rageshakes#11891 matrix-org/element-web-rageshakes#11858 and matrix-org/element-web-rageshakes#11456. + * When selecting reply in thread on a thread response open existing thread ([\#8291](https://github.com/matrix-org/matrix-react-sdk/pull/8291)). Fixes vector-im/element-web#21743. + * Handle thread bundled relationships coming from the server via MSC3666 ([\#8292](https://github.com/matrix-org/matrix-react-sdk/pull/8292)). Fixes vector-im/element-web#21450. + * Fix: Avatar preview does not update when same file is selected repeatedly ([\#8288](https://github.com/matrix-org/matrix-react-sdk/pull/8288)). Fixes vector-im/element-web#20098. + * Fix a bug where user gets a warning when changing powerlevel from **Admin** to **custom level (100)** ([\#8248](https://github.com/matrix-org/matrix-react-sdk/pull/8248)). Fixes vector-im/element-web#21682. Contributed by @Jumeb. + * Use a consistent alignment for all text items in a list ([\#8276](https://github.com/matrix-org/matrix-react-sdk/pull/8276)). Fixes vector-im/element-web#21731. Contributed by @luixxiul. + * Fixes button labels being collapsed per a character in CJK languages ([\#8212](https://github.com/matrix-org/matrix-react-sdk/pull/8212)). Fixes vector-im/element-web#21287. Contributed by @luixxiul. + * Fix: Remove jittery timeline scrolling after jumping to an event ([\#8263](https://github.com/matrix-org/matrix-react-sdk/pull/8263)). + * Fix regression of edits showing up in the timeline with hidden events shown ([\#8260](https://github.com/matrix-org/matrix-react-sdk/pull/8260)). Fixes vector-im/element-web#21694. + * Fix reporting events not working ([\#8257](https://github.com/matrix-org/matrix-react-sdk/pull/8257)). Fixes vector-im/element-web#21713. + * Make Jitsi widgets in video rooms immutable ([\#8244](https://github.com/matrix-org/matrix-react-sdk/pull/8244)). Fixes vector-im/element-web#21647. + * Fix: Ensure links to events scroll the correct events into view ([\#8250](https://github.com/matrix-org/matrix-react-sdk/pull/8250)). Fixes vector-im/element-web#19934. + +Changes in [3.42.4](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.42.4) (2022-04-14) +===================================================================================================== + +## 🐛 Bug Fixes + * Fixes around threads beta in degraded mode ([\#8319](https://github.com/matrix-org/matrix-react-sdk/pull/8319)). Fixes vector-im/element-web#21762. + Changes in [3.42.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.42.3) (2022-04-12) ===================================================================================================== @@ -477,7 +528,7 @@ Changes in [3.39.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/ ===================================================================================================== ## 🐛 Bug Fixes - * Fix the sticker picker ([\#7692](https://github.com/matrix-org/matrix-react-sdk/pull/7692)). Fixes vector-im/element-web#20797. + * Fix the sticker picker ([\#7692](https://github.com/matrix-org/matrix-react-sdk/pull/7692)). Fixes vector-im/element-web#20797. * Ensure UserInfo can be rendered without a room ([\#7687](https://github.com/matrix-org/matrix-react-sdk/pull/7687)). Fixes vector-im/element-web#20830. * Fix publishing address wrongly demanding the alias be available ([\#7690](https://github.com/matrix-org/matrix-react-sdk/pull/7690)). Fixes vector-im/element-web#12013 and vector-im/element-web#20833. @@ -1336,7 +1387,7 @@ Changes in [3.32.1](https://github.com/vector-im/element-desktop/releases/tag/v3 ## 🐛 Bug Fixes * Upgrade to matrix-js-sdk#14.0.1 - + Changes in [3.32.0](https://github.com/vector-im/element-desktop/releases/tag/v3.32.0) (2021-10-11) =================================================================================================== @@ -2482,7 +2533,7 @@ related to file upload. When uploading a file, the local file preview can lead to execution of scripts embedded in the uploaded file, but only after several user interactions to open the preview in a separate tab. This only impacts the local user while in the process of uploading. It cannot be exploited remotely -or by other users. Thanks to [Muhammad Zaid Ghifari](https://github.com/MR-ZHEEV) +or by other users. Thanks to [Muhammad Zaid Ghifari](https://github.com/MR-ZHEEV) for responsibly disclosing this via Matrix's Security Disclosure Policy. ## All changes @@ -6477,7 +6528,7 @@ Changes in [2.1.0-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/ ============================================================================================================= [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.1.0-rc.1...v2.1.0-rc.2) - * Fix error in previous attempt to upgrade JS SDK + * Fix error in previous attempt to upgrade JS SDK Changes in [2.1.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.1.0-rc.1) (2020-02-13) ============================================================================================================= diff --git a/README.md b/README.md index 4664887360a..1312e56a5b2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,12 @@ +[![npm](https://img.shields.io/npm/v/matrix-react-sdk)](https://www.npmjs.com/package/matrix-react-sdk) +![Tests](https://github.com/matrix-org/matrix-react-sdk/actions/workflows/tests.yml/badge.svg) +![Static Analysis](https://github.com/matrix-org/matrix-react-sdk/actions/workflows/static_analysis.yaml/badge.svg) +[![Weblate](https://translate.element.io/widgets/element-web/-/matrix-react-sdk/svg-badge.svg)](https://translate.element.io/engage/element-web/) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=matrix-react-sdk&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=matrix-react-sdk) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=matrix-react-sdk&metric=coverage)](https://sonarcloud.io/summary/new_code?id=matrix-react-sdk) +[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=matrix-react-sdk&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=matrix-react-sdk) +[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=matrix-react-sdk&metric=bugs)](https://sonarcloud.io/summary/new_code?id=matrix-react-sdk) + matrix-react-sdk ================ diff --git a/__mocks__/maplibre-gl.js b/__mocks__/maplibre-gl.js index 90a30968d9b..599cacde13d 100644 --- a/__mocks__/maplibre-gl.js +++ b/__mocks__/maplibre-gl.js @@ -1,5 +1,5 @@ const EventEmitter = require("events"); -const { LngLat, NavigationControl } = require('maplibre-gl'); +const { LngLat, NavigationControl, LngLatBounds } = require('maplibre-gl'); class MockMap extends EventEmitter { addControl = jest.fn(); @@ -8,6 +8,7 @@ class MockMap extends EventEmitter { zoomOut = jest.fn(); setCenter = jest.fn(); setStyle = jest.fn(); + fitBounds = jest.fn(); } const MockMapInstance = new MockMap(); @@ -24,5 +25,6 @@ module.exports = { GeolocateControl: jest.fn().mockReturnValue(MockGeolocateInstance), Marker: jest.fn().mockReturnValue(MockMarker), LngLat, + LngLatBounds, NavigationControl, }; diff --git a/cypress.json b/cypress.json index 4c1ed2d5856..2c39bb411fe 100644 --- a/cypress.json +++ b/cypress.json @@ -1,4 +1,5 @@ { "baseUrl": "http://localhost:8080", - "videoUploadOnPasses": false + "videoUploadOnPasses": false, + "projectId": "ppvnzg" } diff --git a/cypress/integration/1-register/register.spec.ts b/cypress/integration/1-register/register.spec.ts index f719da55477..f61a10e3046 100644 --- a/cypress/integration/1-register/register.spec.ts +++ b/cypress/integration/1-register/register.spec.ts @@ -16,34 +16,34 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker/index"; +import { SynapseInstance } from "../../plugins/synapsedocker"; describe("Registration", () => { - let synapseId; - let synapsePort; + let synapse: SynapseInstance; beforeEach(() => { - cy.task("synapseStart", "consent").then(result => { - synapseId = result.synapseId; - synapsePort = result.port; - }); cy.visit("/#/register"); + cy.startSynapse("consent").then(data => { + synapse = data; + }); }); afterEach(() => { - cy.task("synapseStop", synapseId); + cy.stopSynapse(synapse); }); it("registers an account and lands on the home screen", () => { cy.get(".mx_ServerPicker_change", { timeout: 15000 }).click(); - cy.get(".mx_ServerPickerDialog_otherHomeserver").type(`http://localhost:${synapsePort}`); + cy.get(".mx_ServerPickerDialog_otherHomeserver").type(synapse.baseUrl); cy.get(".mx_ServerPickerDialog_continue").click(); // wait for the dialog to go away cy.get('.mx_ServerPickerDialog').should('not.exist'); + cy.get("#mx_RegistrationForm_username").type("alice"); cy.get("#mx_RegistrationForm_password").type("totally a great password"); cy.get("#mx_RegistrationForm_passwordConfirm").type("totally a great password"); cy.get(".mx_Login_submit").click(); + cy.get(".mx_RegistrationEmailPromptDialog button.mx_Dialog_primary").click(); cy.get(".mx_InteractiveAuthEntryComponents_termsPolicy input").click(); cy.get(".mx_InteractiveAuthEntryComponents_termsSubmit").click(); diff --git a/cypress/integration/2-login/login.spec.ts b/cypress/integration/2-login/login.spec.ts new file mode 100644 index 00000000000..9fb7ba4792b --- /dev/null +++ b/cypress/integration/2-login/login.spec.ts @@ -0,0 +1,57 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import { SynapseInstance } from "../../plugins/synapsedocker"; + +describe("Login", () => { + let synapse: SynapseInstance; + + beforeEach(() => { + cy.visit("/#/login"); + cy.startSynapse("consent").then(data => { + synapse = data; + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + describe("m.login.password", () => { + const username = "user1234"; + const password = "p4s5W0rD"; + + beforeEach(() => { + cy.registerUser(synapse, username, password); + }); + + it("logs in with an existing account and lands on the home screen", () => { + cy.get(".mx_ServerPicker_change", { timeout: 15000 }).click(); + cy.get(".mx_ServerPickerDialog_otherHomeserver").type(synapse.baseUrl); + cy.get(".mx_ServerPickerDialog_continue").click(); + // wait for the dialog to go away + cy.get('.mx_ServerPickerDialog').should('not.exist'); + + cy.get("#mx_LoginForm_username").type(username); + cy.get("#mx_LoginForm_password").type(password); + cy.get(".mx_Login_submit").click(); + + cy.url().should('contain', '/#/home'); + }); + }); +}); diff --git a/cypress/integration/3-user-menu/user-menu.spec.ts b/cypress/integration/3-user-menu/user-menu.spec.ts new file mode 100644 index 00000000000..b3c482d9f19 --- /dev/null +++ b/cypress/integration/3-user-menu/user-menu.spec.ts @@ -0,0 +1,45 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import { SynapseInstance } from "../../plugins/synapsedocker"; +import type { UserCredentials } from "../../support/login"; + +describe("UserMenu", () => { + let synapse: SynapseInstance; + let user: UserCredentials; + + beforeEach(() => { + cy.startSynapse("consent").then(data => { + synapse = data; + + cy.initTestUser(synapse, "Jeff").then(credentials => { + user = credentials; + }); + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + it("should contain our name & userId", () => { + cy.get('[aria-label="User menu"]', { timeout: 15000 }).click(); + cy.get(".mx_UserMenu_contextMenu_displayName").should("contain", "Jeff"); + cy.get(".mx_UserMenu_contextMenu_userId").should("contain", user.userId); + }); +}); diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts index db01ceceb4f..9438d136064 100644 --- a/cypress/plugins/index.ts +++ b/cypress/plugins/index.ts @@ -16,8 +16,13 @@ limitations under the License. /// -import { synapseDocker } from "./synapsedocker/index"; +import { synapseDocker } from "./synapsedocker"; +import PluginEvents = Cypress.PluginEvents; +import PluginConfigOptions = Cypress.PluginConfigOptions; -export default function(on, config) { +/** + * @type {Cypress.PluginConfig} + */ +export default function(on: PluginEvents, config: PluginConfigOptions) { synapseDocker(on, config); } diff --git a/cypress/plugins/synapsedocker/index.ts b/cypress/plugins/synapsedocker/index.ts index 0f029e7b2ed..af8ddac73c6 100644 --- a/cypress/plugins/synapsedocker/index.ts +++ b/cypress/plugins/synapsedocker/index.ts @@ -21,6 +21,10 @@ import * as os from "os"; import * as crypto from "crypto"; import * as childProcess from "child_process"; import * as fse from "fs-extra"; +import * as net from "net"; + +import PluginEvents = Cypress.PluginEvents; +import PluginConfigOptions = Cypress.PluginConfigOptions; // A cypress plugins to add command to start & stop synapses in // docker with preset templates. @@ -28,11 +32,13 @@ import * as fse from "fs-extra"; interface SynapseConfig { configDir: string; registrationSecret: string; + // Synapse must be configured with its public_baseurl so we have to allocate a port & url at this stage + baseUrl: string; + port: number; } export interface SynapseInstance extends SynapseConfig { synapseId: string; - port: number; } const synapses = new Map(); @@ -41,6 +47,16 @@ function randB64Bytes(numBytes: number): string { return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, ""); } +async function getFreePort(): Promise { + return new Promise(resolve => { + const srv = net.createServer(); + srv.listen(0, () => { + const port = (srv.address()).port; + srv.close(() => resolve(port)); + }); + }); +} + async function cfgDirFromTemplate(template: string): Promise { const templateDir = path.join(__dirname, "templates", template); @@ -61,12 +77,16 @@ async function cfgDirFromTemplate(template: string): Promise { const macaroonSecret = randB64Bytes(16); const formSecret = randB64Bytes(16); - // now copy homeserver.yaml, applying sustitutions + const port = await getFreePort(); + const baseUrl = `http://localhost:${port}`; + + // now copy homeserver.yaml, applying substitutions console.log(`Gen ${path.join(templateDir, "homeserver.yaml")}`); let hsYaml = await fse.readFile(path.join(templateDir, "homeserver.yaml"), "utf8"); hsYaml = hsYaml.replace(/{{REGISTRATION_SECRET}}/g, registrationSecret); hsYaml = hsYaml.replace(/{{MACAROON_SECRET_KEY}}/g, macaroonSecret); hsYaml = hsYaml.replace(/{{FORM_SECRET}}/g, formSecret); + hsYaml = hsYaml.replace(/{{PUBLIC_BASEURL}}/g, baseUrl); await fse.writeFile(path.join(tempDir, "homeserver.yaml"), hsYaml); // now generate a signing key (we could use synapse's config generation for @@ -77,6 +97,8 @@ async function cfgDirFromTemplate(template: string): Promise { await fse.writeFile(path.join(tempDir, "localhost.signing.key"), `ed25519 x ${signingKey}`); return { + port, + baseUrl, configDir: tempDir, registrationSecret, }; @@ -98,7 +120,7 @@ async function synapseStart(template: string): Promise { "--name", containerName, "-d", "-v", `${synCfg.configDir}:/data`, - "-p", "8008/tcp", + "-p", `${synCfg.port}:8008/tcp`, "matrixdotorg/synapse:develop", "run", ], (err, stdout) => { @@ -107,30 +129,31 @@ async function synapseStart(template: string): Promise { }); }); - // Get the port that docker allocated: specifying only one - // port above leaves docker to just grab a free one, although - // in hindsight we need to put the port in public_baseurl in the - // config really, so this will probably need changing to use a fixed - // / configured port. - const port = await new Promise((resolve, reject) => { - childProcess.execFile('docker', [ - "port", synapseId, "8008", + synapses.set(synapseId, { synapseId, ...synCfg }); + + console.log(`Started synapse with id ${synapseId} on port ${synCfg.port}.`); + + // Await Synapse healthcheck + await new Promise((resolve, reject) => { + childProcess.execFile("docker", [ + "exec", synapseId, + "curl", + "--connect-timeout", "30", + "--retry", "30", + "--retry-delay", "1", + "--retry-all-errors", + "--silent", + "http://localhost:8008/health", ], { encoding: 'utf8' }, (err, stdout) => { if (err) reject(err); - resolve(Number(stdout.trim().split(":")[1])); + else resolve(); }); }); - synapses.set(synapseId, Object.assign({ - port, - synapseId, - }, synCfg)); - - console.log(`Started synapse with id ${synapseId} on port ${port}.`); return synapses.get(synapseId); } -async function synapseStop(id) { +async function synapseStop(id: string): Promise { const synCfg = synapses.get(id); if (!synCfg) throw new Error("Unknown synapse ID"); @@ -186,10 +209,10 @@ async function synapseStop(id) { /** * @type {Cypress.PluginConfig} */ -// eslint-disable-next-line no-unused-vars -export function synapseDocker(on, config) { +export function synapseDocker(on: PluginEvents, config: PluginConfigOptions) { on("task", { - synapseStart, synapseStop, + synapseStart, + synapseStop, }); on("after:spec", async (spec) => { @@ -197,7 +220,7 @@ export function synapseDocker(on, config) { // This is on the theory that we should avoid re-using synapse // instances between spec runs: they should be cheap enough to // start that we can have a separate one for each spec run or even - // test. If we accidentally re-use synapses, we could inadvertantly + // test. If we accidentally re-use synapses, we could inadvertently // make our tests depend on each other. for (const synId of synapses.keys()) { console.warn(`Cleaning up synapse ID ${synId} after ${spec.name}`); diff --git a/cypress/plugins/synapsedocker/templates/consent/homeserver.yaml b/cypress/plugins/synapsedocker/templates/consent/homeserver.yaml index e26133f6d11..6decaeb5a0b 100644 --- a/cypress/plugins/synapsedocker/templates/consent/homeserver.yaml +++ b/cypress/plugins/synapsedocker/templates/consent/homeserver.yaml @@ -1,6 +1,6 @@ server_name: "localhost" pid_file: /data/homeserver.pid -public_baseurl: http://localhost:5005/ +public_baseurl: "{{PUBLIC_BASEURL}}" listeners: - port: 8008 tls: false diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 9901ef4cb80..598cc4de7ed 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -1,3 +1,20 @@ -// Empty file to prevent cypress from recreating a helpful example -// file on every run (their example file doesn't use semicolons and -// so fails our lint rules). +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import "./synapse"; +import "./login"; diff --git a/cypress/support/login.ts b/cypress/support/login.ts new file mode 100644 index 00000000000..2d7d3ef84af --- /dev/null +++ b/cypress/support/login.ts @@ -0,0 +1,86 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import Chainable = Cypress.Chainable; +import { SynapseInstance } from "../plugins/synapsedocker"; + +export interface UserCredentials { + accessToken: string; + userId: string; + deviceId: string; + password: string; + homeServer: string; +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** + * Generates a test user and instantiates an Element session with that user. + * @param synapse the synapse returned by startSynapse + * @param displayName the displayName to give the test user + */ + initTestUser(synapse: SynapseInstance, displayName: string): Chainable; + } + } +} + +Cypress.Commands.add("initTestUser", (synapse: SynapseInstance, displayName: string): Chainable => { + const username = Cypress._.uniqueId("userId_"); + const password = Cypress._.uniqueId("password_"); + return cy.registerUser(synapse, username, password, displayName).then(() => { + const url = `${synapse.baseUrl}/_matrix/client/r0/login`; + return cy.request<{ + access_token: string; + user_id: string; + device_id: string; + home_server: string; + }>({ + url, + method: "POST", + body: { + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": username, + }, + "password": password, + }, + }); + }).then(response => { + return cy.window().then(win => { + // Seed the localStorage with the required credentials + win.localStorage.setItem("mx_hs_url", synapse.baseUrl); + win.localStorage.setItem("mx_user_id", response.body.user_id); + win.localStorage.setItem("mx_access_token", response.body.access_token); + win.localStorage.setItem("mx_device_id", response.body.device_id); + win.localStorage.setItem("mx_is_guest", "false"); + win.localStorage.setItem("mx_has_pickle_key", "false"); + win.localStorage.setItem("mx_has_access_token", "true"); + + return cy.visit("/").then(() => ({ + password, + accessToken: response.body.access_token, + userId: response.body.user_id, + deviceId: response.body.device_id, + homeServer: response.body.home_server, + })); + }); + }); +}); diff --git a/cypress/support/synapse.ts b/cypress/support/synapse.ts new file mode 100644 index 00000000000..1571ddef366 --- /dev/null +++ b/cypress/support/synapse.ts @@ -0,0 +1,121 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import * as crypto from 'crypto'; + +import Chainable = Cypress.Chainable; +import AUTWindow = Cypress.AUTWindow; +import { SynapseInstance } from "../plugins/synapsedocker"; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** + * Start a synapse instance with a given config template. + * @param template path to template within cypress/plugins/synapsedocker/template/ directory. + */ + startSynapse(template: string): Chainable; + + /** + * Custom command wrapping task:synapseStop whilst preventing uncaught exceptions + * for if Synapse stopping races with the app's background sync loop. + * @param synapse the synapse instance returned by startSynapse + */ + stopSynapse(synapse: SynapseInstance): Chainable; + + /** + * Register a user on the given Synapse using the shared registration secret. + * @param synapse the synapse instance returned by startSynapse + * @param username the username of the user to register + * @param password the password of the user to register + * @param displayName optional display name to set on the newly registered user + */ + registerUser( + synapse: SynapseInstance, + username: string, + password: string, + displayName?: string, + ): Chainable; + } + } +} + +function startSynapse(template: string): Chainable { + return cy.task("synapseStart", template); +} + +function stopSynapse(synapse: SynapseInstance): Chainable { + // Navigate away from app to stop the background network requests which will race with Synapse shutting down + return cy.window().then((win) => { + win.location.href = 'about:blank'; + cy.task("synapseStop", synapse.synapseId); + }); +} + +interface Credentials { + accessToken: string; + userId: string; + deviceId: string; + homeServer: string; +} + +function registerUser( + synapse: SynapseInstance, + username: string, + password: string, + displayName?: string, +): Chainable { + const url = `${synapse.baseUrl}/_synapse/admin/v1/register`; + return cy.then(() => { + // get a nonce + return cy.request<{ nonce: string }>({ url }); + }).then(response => { + const { nonce } = response.body; + const mac = crypto.createHmac('sha1', synapse.registrationSecret).update( + `${nonce}\0${username}\0${password}\0notadmin`, + ).digest('hex'); + + return cy.request<{ + access_token: string; + user_id: string; + home_server: string; + device_id: string; + }>({ + url, + method: "POST", + body: { + nonce, + username, + password, + mac, + admin: false, + displayname: displayName, + }, + }); + }).then(response => ({ + homeServer: response.body.home_server, + accessToken: response.body.access_token, + userId: response.body.user_id, + deviceId: response.body.device_id, + })); +} + +Cypress.Commands.add("startSynapse", startSynapse); +Cypress.Commands.add("stopSynapse", stopSynapse); +Cypress.Commands.add("registerUser", registerUser); diff --git a/docs/cypress.md b/docs/cypress.md new file mode 100644 index 00000000000..95b9b330d11 --- /dev/null +++ b/docs/cypress.md @@ -0,0 +1,163 @@ +# Cypress in Element Web + +## Scope of this Document +This doc is about our Cypress tests in Element Web and how we use Cypress to write tests. +It aims to cover: + * How to run the tests yourself + * How the tests work + * How to write great Cypress tests + +## Running the Tests +Our Cypress tests run automatically as part of our CI along with our other tests, +on every pull request and on every merge to develop & master. + +However the Cypress tests are run, an element-web must be running on +http://localhost:8080 (this is configured in `cypress.json`) - this is what will +be tested. When running Cypress tests yourself, the standard `yarn start` from the +element-web project is fine: leave it running it a different terminal as you would +when developing. + +The tests use Docker to launch Synapse instances to test against, so you'll also +need to have Docker installed and working in order to run the Cypress tests. + +There are a few different ways to run the tests yourself. The simplest is to run: + +``` +yarn run test:cypress +``` + +This will run the Cypress tests once, non-interactively. + +You can also run individual tests this way too, as you'd expect: + +``` +yarn run test:cypress cypress/integration/1-register/register.spec.ts +``` + +Cypress also has its own UI that you can use to run and debug the tests. +To launch it: + +``` +yarn run test:cypress:open +``` + +## How the Tests Work +Everything Cypress-related lives in the `cypress/` subdirectory of react-sdk +as is typical for Cypress tests. Likewise, tests live in `cypress/integration`. + +`cypress/plugins/synapsedocker` contains a Cypress plugin that starts instances +of Synapse in Docker containers. These synapses are what Element-web runs against +in the Cypress tests. + +Synapse can be launched with different configurations in order to test element +in different configurations. `cypress/plugins/synapsedocker/templates` contains +template configuration files for each different configuration. + +Each test suite can then launch whatever Synapse instances it needs it whatever +configurations. + +Note that although tests should stop the Synapse instances after running and the +plugin also stop any remaining instances after all tests have run, it is possible +to be left with some stray containers if, for example, you terminate a test such +that the `after()` does not run and also exit Cypress uncleanly. All the containers +it starts are prefixed, so they are easy to recognise. They can be removed safely. + +After each test run, logs from the Synapse instances are saved in `cypress/synapselogs` +with each instance in a separate directory named after its ID. These logs are removed +at the start of each test run. + +## Writing Tests +Mostly this is the same advice as for writing any other Cypress test: the Cypress +docs are well worth a read if you're not already familiar with Cypress testing, eg. +https://docs.cypress.io/guides/references/best-practices . + +### Getting a Synapse +The key difference is in starting Synapse instances. Tests use this plugin via +`cy.startSynapse()` to provide a Synapse instance to log into: + +```javascript +cy.startSynapse("consent").then(result => { + synapse = result; +}); +``` + +This returns an object with information about the Synapse instance, including what port +it was started on and the ID that needs to be passed to shut it down again. It also +returns the registration shared secret (`registrationSecret`) that can be used to +register users via the REST API. The Synapse has been ensured ready to go by awaiting +its internal health-check. + +Synapse instances should be reasonably cheap to start (you may see the first one take a +while as it pulls the Docker image), so it's generally expected that tests will start a +Synapse instance for each test suite, i.e. in `before()`, and then tear it down in `after()`. + +To later destroy your Synapse you should call `stopSynapse`, passing the SynapseInstance +object you received when starting it. +```javascript +cy.stopSynapse(synapse); +``` + +### Synapse Config Templates +When a Synapse instance is started, it's given a config generated from one of the config +templates in `cypress/plugins/synapsedocker/templates`. There are a couple of special files +in these templates: + * `homeserver.yaml`: + Template substitution happens in this file. Template variables are: + * `REGISTRATION_SECRET`: The secret used to register users via the REST API. + * `MACAROON_SECRET_KEY`: Generated each time for security + * `FORM_SECRET`: Generated each time for security + * `PUBLIC_BASEURL`: The localhost url + port combination the synapse is accessible at + * `localhost.signing.key`: A signing key is auto-generated and saved to this file. + Config templates should not contain a signing key and instead assume that one will exist + in this file. + +All other files in the template are copied recursively to `/data/`, so the file `foo.html` +in a template can be referenced in the config as `/data/foo.html`. + +### Logging In +There exists a basic utility to start the app with a random user already logged in: +```javascript +cy.initTestUser(synapse, "Jeff"); +``` +It takes the SynapseInstance you received from `startSynapse` and a display name for your test user. +This custom command will register a random userId using the registrationSecret with a random password +and the given display name. The returned Chainable will contain details about the credentials for if +they are needed for User-Interactive Auth or similar but localStorage will already be seeded with them +and the app loaded (path `/`). + +The internals of how this custom command run may be swapped out later, +but the signature can be maintained for simpler maintenance. + +### Joining a Room +Many tests will also want to start with the client in a room, ready to send & receive messages. Best +way to do this may be to get an access token for the user and use this to create a room with the REST +API before logging the user in. + +### Convenience APIs +We should probably end up with convenience APIs that wrap the synapse creation, logging in and room +creation that can be called to set up tests. + +## Good Test Hygiene +This section mostly summarises general good Cypress testing practice, and should not be news to anyone +already familiar with Cypress. + +1. Test a well-isolated unit of functionality. The more specific, the easier it will be to tell what's + wrong when they fail. +1. Don't depend on state from other tests: any given test should be able to run in isolation. +1. Try to avoid driving the UI for anything other than the UI you're trying to test. e.g. if you're + testing that the user can send a reaction to a message, it's best to send a message using a REST + API, then react to it using the UI, rather than using the element-web UI to send the message. +1. Avoid explicit waits. `cy.get()` will implicitly wait for the specified element to appear and + all assertions are retired until they either pass or time out, so you should never need to + manually wait for an element. + * For example, for asserting about editing an already-edited message, you can't wait for the + 'edited' element to appear as there was already one there, but you can assert that the body + of the message is what is should be after the second edit and this assertion will pass once + it becomes true. You can then assert that the 'edited' element is still in the DOM. + * You can also wait for other things like network requests in the + browser to complete (https://docs.cypress.io/guides/guides/network-requests#Waiting). + Needing to wait for things can also be because of race conditions in the app itself, which ideally + shouldn't be there! + +This is a small selection - the Cypress best practices guide, linked above, has more good advice, and we +should generally try to adhere to them. diff --git a/package.json b/package.json index 987c12eb06d..808ede86b9d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.42.3", + "version": "3.43.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -22,7 +22,7 @@ "README.md", "package.json" ], - "main": "./src/index.ts", + "main": "./lib/index.ts", "matrix_src_main": "./src/index.ts", "matrix_lib_main": "./lib/index.ts", "matrix_lib_typings": "./lib/index.d.ts", @@ -83,12 +83,12 @@ "is-ip": "^3.1.0", "jszip": "^3.7.0", "katex": "^0.12.0", - "linkify-element": "^4.0.0-beta.4", - "linkify-string": "^4.0.0-beta.4", - "linkifyjs": "^4.0.0-beta.4", + "linkify-element": "4.0.0-beta.4", + "linkify-string": "4.0.0-beta.4", + "linkifyjs": "4.0.0-beta.4", "lodash": "^4.17.20", "maplibre-gl": "^1.15.2", - "matrix-analytics-events": "github:matrix-org/matrix-analytics-events.git#daad3faed54f0b1f1e026a7498b4653e4d01cd90", + "matrix-analytics-events": "github:matrix-org/matrix-analytics-events.git#4aef17b56798639906f26a8739043a3c5c5fde7e", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "^0.0.1-beta.7", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", @@ -173,7 +173,7 @@ "eslint-config-google": "^0.14.0", "eslint-plugin-import": "^2.25.4", "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-matrix-org": "^0.4.0", + "eslint-plugin-matrix-org": "^0.5.2", "eslint-plugin-react": "^7.28.0", "eslint-plugin-react-hooks": "^4.3.0", "fs-extra": "^10.0.1", @@ -184,6 +184,7 @@ "jest-fetch-mock": "^3.0.3", "jest-mock": "^27.5.1", "jest-raw-loader": "^1.0.1", + "jest-sonar-reporter": "^2.0.0", "matrix-mock-request": "^1.2.3", "matrix-react-test-utils": "^0.2.3", "matrix-web-i18n": "^1.2.0", @@ -233,8 +234,14 @@ "/src/**/*.{js,ts,tsx}" ], "coverageReporters": [ - "text", - "json" - ] - } + "text-summary", + "lcov" + ], + "testResultsProcessor": "jest-sonar-reporter" + }, + "jestSonar": { + "reportPath": "coverage", + "sonar56x": true + }, + "typings": "./lib/index.d.ts" } diff --git a/res/css/_common.scss b/res/css/_common.scss index 5c6349c2204..94ec5ea0115 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -294,7 +294,6 @@ legend { background-color: $background; color: $light-fg-color; z-index: 4012; - font-weight: 300; font-size: $font-15px; position: relative; padding: 24px; @@ -692,3 +691,32 @@ legend { } } } + +@define-mixin ThreadsAmount { + $threadInfoLineHeight: calc(2 * $font-12px); + + color: $secondary-content; + font-weight: $font-semi-bold; + line-height: $threadInfoLineHeight; + white-space: nowrap; + position: relative; + padding: 0 $spacing-12 0 $spacing-8; +} + +@define-mixin ThreadInfoIcon { + content: ""; + display: inline-block; + mask-image: url('$(res)/img/element-icons/thread-summary.svg'); + mask-position: center; + height: 18px; + min-width: 18px; + background-color: $secondary-content !important; + mask-repeat: no-repeat; + mask-size: contain; +} + +@define-mixin ListResetDefault { + list-style: none; + padding: 0; + margin: 0; +} diff --git a/res/css/_components.scss b/res/css/_components.scss index 8b5dc63a0e6..6988c017895 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -4,13 +4,17 @@ @import "./_font-sizes.scss"; @import "./_font-weights.scss"; @import "./_spacing.scss"; +@import "./components/views/beacon/_BeaconListItem.scss"; @import "./components/views/beacon/_BeaconStatus.scss"; @import "./components/views/beacon/_BeaconViewDialog.scss"; +@import "./components/views/beacon/_DialogOwnBeaconStatus.scss"; +@import "./components/views/beacon/_DialogSidebar.scss"; @import "./components/views/beacon/_LeftPanelLiveShareWarning.scss"; @import "./components/views/beacon/_LiveTimeRemaining.scss"; @import "./components/views/beacon/_OwnBeaconStatus.scss"; @import "./components/views/beacon/_RoomLiveShareWarning.scss"; @import "./components/views/beacon/_StyledLiveBeaconIcon.scss"; +@import "./components/views/location/_EnableLiveShare.scss"; @import "./components/views/location/_LiveDurationDropdown.scss"; @import "./components/views/location/_LocationShareMenu.scss"; @import "./components/views/location/_MapError.scss"; @@ -24,7 +28,6 @@ @import "./structures/_BackdropPanel.scss"; @import "./structures/_CompatibilityPage.scss"; @import "./structures/_ContextualMenu.scss"; -@import "./structures/_CreateRoom.scss"; @import "./structures/_FileDropTarget.scss"; @import "./structures/_FilePanel.scss"; @import "./structures/_GenericErrorPage.scss"; @@ -50,6 +53,7 @@ @import "./structures/_ToastContainer.scss"; @import "./structures/_UploadBar.scss"; @import "./structures/_UserMenu.scss"; +@import "./structures/_VideoRoomView.scss"; @import "./structures/_ViewSource.scss"; @import "./structures/auth/_CompleteSecurity.scss"; @import "./structures/auth/_Login.scss"; @@ -76,6 +80,7 @@ @import "./views/avatars/_WidgetAvatar.scss"; @import "./views/beta/_BetaCard.scss"; @import "./views/context_menus/_CallContextMenu.scss"; +@import "./views/context_menus/_DeviceContextMenu.scss"; @import "./views/context_menus/_IconizedContextMenu.scss"; @import "./views/context_menus/_MessageContextMenu.scss"; @import "./views/dialogs/_AddExistingToSpaceDialog.scss"; @@ -121,7 +126,6 @@ @import "./views/dialogs/_SpacePreferencesDialog.scss"; @import "./views/dialogs/_SpaceSettingsDialog.scss"; @import "./views/dialogs/_SpotlightDialog.scss"; -@import "./views/dialogs/_TabbedIntegrationManagerDialog.scss"; @import "./views/dialogs/_TermsDialog.scss"; @import "./views/dialogs/_UntrustedDeviceDialog.scss"; @import "./views/dialogs/_UploadConfirmDialog.scss"; @@ -158,6 +162,7 @@ @import "./views/elements/_InviteReason.scss"; @import "./views/elements/_ManageIntegsButton.scss"; @import "./views/elements/_MiniAvatarUploader.scss"; +@import "./views/elements/_Pill.scss"; @import "./views/elements/_PowerSelector.scss"; @import "./views/elements/_ProgressBar.scss"; @import "./views/elements/_QRCode.scss"; @@ -245,20 +250,24 @@ @import "./views/rooms/_NotificationBadge.scss"; @import "./views/rooms/_PinnedEventTile.scss"; @import "./views/rooms/_PresenceLabel.scss"; +@import "./views/rooms/_ReadReceiptGroup.scss"; @import "./views/rooms/_RecentlyViewedButton.scss"; @import "./views/rooms/_ReplyPreview.scss"; @import "./views/rooms/_ReplyTile.scss"; @import "./views/rooms/_RoomBreadcrumbs.scss"; @import "./views/rooms/_RoomHeader.scss"; +@import "./views/rooms/_RoomInfoLine.scss"; @import "./views/rooms/_RoomList.scss"; @import "./views/rooms/_RoomListHeader.scss"; @import "./views/rooms/_RoomPreviewBar.scss"; +@import "./views/rooms/_RoomPreviewCard.scss"; @import "./views/rooms/_RoomSublist.scss"; @import "./views/rooms/_RoomTile.scss"; @import "./views/rooms/_RoomUpgradeWarningBar.scss"; @import "./views/rooms/_SearchBar.scss"; @import "./views/rooms/_SendMessageComposer.scss"; @import "./views/rooms/_Stickers.scss"; +@import "./views/rooms/_ThreadSummary.scss"; @import "./views/rooms/_TopUnreadMessagesBar.scss"; @import "./views/rooms/_VoiceRecordComposerTile.scss"; @import "./views/rooms/_WhoIsTypingTile.scss"; @@ -321,3 +330,4 @@ @import "./views/voip/_DialPadModal.scss"; @import "./views/voip/_PiPContainer.scss"; @import "./views/voip/_VideoFeed.scss"; +@import "./views/voip/_VideoLobby.scss"; diff --git a/res/css/components/views/beacon/_BeaconListItem.scss b/res/css/components/views/beacon/_BeaconListItem.scss new file mode 100644 index 00000000000..60311a4466f --- /dev/null +++ b/res/css/components/views/beacon/_BeaconListItem.scss @@ -0,0 +1,61 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_BeaconListItem { + box-sizing: border-box; + display: flex; + flex-direction: row; + align-items: flex-start; + padding: $spacing-12 0; + + border-bottom: 1px solid $system; +} + +.mx_BeaconListItem_avatarIcon { + flex: 0 0; + height: 32px; + width: 32px; +} + +.mx_BeaconListItem_avatar { + flex: 0 0; + box-sizing: border-box; + + margin-right: $spacing-8; + border: 2px solid $location-live-color; +} + +.mx_BeaconListItem_info { + flex: 1 1 0; + display: flex; + flex-direction: column; + align-items: stretch; +} + +.mx_BeaconListItem_status { + // override beacon status padding + padding: 0 !important; + margin-bottom: $spacing-8; + + .mx_BeaconStatus_label { + font-weight: $font-semi-bold; + } +} + +.mx_BeaconListItem_lastUpdated { + color: $tertiary-content; + font-size: $font-10px; +} diff --git a/res/css/components/views/beacon/_BeaconStatus.scss b/res/css/components/views/beacon/_BeaconStatus.scss index 8ac873604d2..4dd3d325475 100644 --- a/res/css/components/views/beacon/_BeaconStatus.scss +++ b/res/css/components/views/beacon/_BeaconStatus.scss @@ -59,3 +59,7 @@ limitations under the License. .mx_BeaconStatus_expiryTime { color: $secondary-content; } + +.mx_BeaconStatus_label { + margin-bottom: 2px; +} diff --git a/res/css/components/views/beacon/_BeaconViewDialog.scss b/res/css/components/views/beacon/_BeaconViewDialog.scss index 901b4564395..6ad1a2a6139 100644 --- a/res/css/components/views/beacon/_BeaconViewDialog.scss +++ b/res/css/components/views/beacon/_BeaconViewDialog.scss @@ -29,6 +29,9 @@ limitations under the License. height: calc(80vh - 0.5px); overflow: hidden; + // sidebar is absolutely positioned inside + position: relative; + .mx_Dialog_header { margin: 0px; padding: 0px; @@ -40,7 +43,7 @@ limitations under the License. .mx_Dialog_cancelButton { z-index: 4010; - position: absolute; + position: fixed; right: 5vw; top: 5vh; width: 20px; @@ -55,3 +58,31 @@ limitations under the License. height: 80vh; border-radius: 8px; } + +.mx_BeaconViewDialog_mapFallback { + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + background: url('$(res)/img/location/map.svg'); + background-size: cover; +} + +.mx_BeaconViewDialog_mapFallbackIcon { + width: 65px; + margin-bottom: $spacing-16; + color: $quaternary-content; +} + +.mx_BeaconViewDialog_mapFallbackMessage { + color: $secondary-content; + margin-bottom: $spacing-16; +} + +.mx_BeaconViewDialog_viewListButton { + position: absolute; + top: $spacing-24; + left: $spacing-24; +} diff --git a/res/css/components/views/beacon/_DialogOwnBeaconStatus.scss b/res/css/components/views/beacon/_DialogOwnBeaconStatus.scss new file mode 100644 index 00000000000..791e276f050 --- /dev/null +++ b/res/css/components/views/beacon/_DialogOwnBeaconStatus.scss @@ -0,0 +1,55 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_DialogOwnBeaconStatus { + position: absolute; + bottom: $spacing-32; + width: 300px; + margin-left: -150px; + left: 50%; + + box-sizing: border-box; + display: flex; + flex-direction: row; + align-items: flex-start; + justify-content: stretch; + + background: $background; + border-radius: 8px; + box-shadow: 4px 4px 12px 0 $menu-box-shadow-color; + + padding: 0 $spacing-12; +} + +.mx_DialogOwnBeaconStatus_avatarIcon { + flex: 0 0; + height: 32px; + width: 32px; + margin: $spacing-8 0 $spacing-8 0; +} + +.mx_DialogOwnBeaconStatus_avatar { + flex: 0 0; + box-sizing: border-box; + + border: 2px solid $location-live-color; + margin: $spacing-8 0 $spacing-8 0; +} + +.mx_DialogOwnBeaconStatus_status { + flex: 1 1; + padding-right: 0; +} diff --git a/res/css/components/views/beacon/_DialogSidebar.scss b/res/css/components/views/beacon/_DialogSidebar.scss new file mode 100644 index 00000000000..1989b57c301 --- /dev/null +++ b/res/css/components/views/beacon/_DialogSidebar.scss @@ -0,0 +1,60 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_DialogSidebar { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 265px; + + display: flex; + flex-direction: column; + + box-sizing: border-box; + padding: $spacing-16; + + background-color: $background; + box-shadow: 0px 4px 4px $menu-box-shadow-color; +} + +.mx_DialogSidebar_header { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + + flex: 0 0; + margin-bottom: $spacing-16; + + color: $primary-content; +} + +.mx_DialogSidebar_closeButton { + @mixin ButtonResetDefault; +} + +.mx_DialogSidebar_closeButtonIcon { + color: $tertiary-content; + height: 12px; +} + +.mx_DialogSidebar_list { + @mixin ListResetDefault; + flex: 1 1 0; + width: 100%; + overflow: auto; +} diff --git a/res/css/components/views/location/_EnableLiveShare.scss b/res/css/components/views/location/_EnableLiveShare.scss new file mode 100644 index 00000000000..7c10cc1923f --- /dev/null +++ b/res/css/components/views/location/_EnableLiveShare.scss @@ -0,0 +1,48 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_EnableLiveShare { + flex: 1 1 0; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; + + padding: $spacing-32 $spacing-16; + text-align: center; + box-sizing: border-box; +} + +.mx_EnableLiveShare_heading { + padding-top: $spacing-24; +} + +.mx_EnableLiveShare_icon { + height: 58px; + width: 58px; +} + +.mx_EnableLiveShare_description { + padding: 0 $spacing-24; + margin-bottom: $spacing-32; + line-height: $font-20px; +} + +.mx_EnableLiveShare_button { + margin-top: $spacing-32; + height: 48px; + width: 100%; +} diff --git a/res/css/structures/_RoomDirectory.scss b/res/css/structures/_RoomDirectory.scss index 0137db7ebf2..2b8def01d03 100644 --- a/res/css/structures/_RoomDirectory.scss +++ b/res/css/structures/_RoomDirectory.scss @@ -155,7 +155,7 @@ limitations under the License. line-height: $font-20px; padding: 0 5px; color: $accent-fg-color; - background-color: $rte-room-pill-color; + background-color: $pill-bg-color; } .mx_RoomDirectory_topic { diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index 84e6041ecd5..eba8ae8f6e8 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -70,10 +70,6 @@ limitations under the License. overflow-y: auto; flex: 1 1 0; overflow-anchor: none; - - &[data-scrollbar=false] { - overflow-y: hidden; - } } .mx_RoomView_messagePanelSearchSpinner { @@ -211,21 +207,9 @@ hr.mx_RoomView_myReadMarker { opacity: 1; } -// Immersive widgets -.mx_RoomView_immersive { - .mx_RoomHeader_wrapper { - border: unset; - } - - .mx_AppTile { - margin: $container-gap-width; - margin-right: calc($container-gap-width / 2); - width: auto; - height: 100%; - padding-top: 33px; // to match the right panel chat heading - - border-radius: 8px; - } +// Rooms with immersive content +.mx_RoomView_immersive .mx_RoomHeader_wrapper { + border: unset; } .mx_RoomView_callStatusBar .mx_UploadBar_uploadProgressInner { diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index 1cbc7cd7352..9d23dd5fc93 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -155,6 +155,7 @@ $activeBorderColor: $primary-content; border-radius: 12px; padding: 4px; width: calc(100% - 32px); + min-width: 0; } .mx_SpaceButton_name { @@ -274,6 +275,7 @@ $activeBorderColor: $primary-content; display: flex; flex-direction: column; max-width: 250px; + min-width: 0; flex-grow: 1; .mx_BaseAvatar:not(.mx_UserMenu_userAvatar_BaseAvatar) .mx_BaseAvatar_initial { diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss index eed3d8830f6..f4d37e0e246 100644 --- a/res/css/structures/_SpaceRoomView.scss +++ b/res/css/structures/_SpaceRoomView.scss @@ -137,124 +137,6 @@ $SpaceRoomViewInnerWidth: 428px; } } - .mx_SpaceRoomView_preview, - .mx_SpaceRoomView_landing { - .mx_SpaceRoomView_info_memberCount { - color: inherit; - position: relative; - padding: 0 0 0 16px; - font-size: $font-15px; - display: inline; // cancel inline-flex - - &::before { - content: "·"; // visual separator - position: absolute; - left: 6px; - } - } - } - - .mx_SpaceRoomView_preview { - padding: 32px 24px !important; // override default padding from above - margin: auto; - max-width: 480px; - box-sizing: border-box; - box-shadow: 2px 15px 30px $dialog-shadow-color; - border-radius: 8px; - position: relative; - - // XXX remove this when spaces leaves Beta - .mx_BetaCard_betaPill { - position: absolute; - right: 24px; - top: 32px; - } - - // XXX remove this when spaces leaves Beta - .mx_SpaceRoomView_preview_spaceBetaPrompt { - font-weight: $font-semi-bold; - font-size: $font-14px; - line-height: $font-24px; - color: $primary-content; - margin-top: 24px; - position: relative; - padding-left: 24px; - - .mx_AccessibleButton_kind_link { - display: inline; - padding: 0; - font-size: inherit; - line-height: inherit; - } - - &::before { - content: ""; - position: absolute; - height: $font-24px; - width: 20px; - left: 0; - 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_SpaceRoomView_preview_inviter { - display: flex; - align-items: center; - margin-bottom: 20px; - font-size: $font-15px; - - > div { - margin-left: 8px; - - .mx_SpaceRoomView_preview_inviter_name { - line-height: $font-18px; - } - - .mx_SpaceRoomView_preview_inviter_mxid { - line-height: $font-24px; - color: $secondary-content; - } - } - } - - > .mx_RoomAvatar_isSpaceRoom { - &.mx_BaseAvatar_image, .mx_BaseAvatar_image { - border-radius: 12px; - } - } - - h1.mx_SpaceRoomView_preview_name { - margin: 20px 0 !important; // override default margin from above - } - - .mx_SpaceRoomView_preview_topic { - font-size: $font-14px; - line-height: $font-22px; - color: $secondary-content; - margin: 20px 0; - max-height: 160px; - overflow-y: auto; - } - - .mx_SpaceRoomView_preview_joinButtons { - margin-top: 20px; - - .mx_AccessibleButton { - width: 200px; - box-sizing: border-box; - padding: 14px 0; - - & + .mx_AccessibleButton { - margin-left: 20px; - } - } - } - } - .mx_SpaceRoomView_landing { display: flex; flex-direction: column; @@ -314,40 +196,6 @@ $SpaceRoomViewInnerWidth: 428px; flex-wrap: wrap; line-height: $font-24px; - .mx_SpaceRoomView_info { - color: $secondary-content; - font-size: $font-15px; - display: inline-block; - - .mx_SpaceRoomView_info_public, - .mx_SpaceRoomView_info_private { - padding-left: 20px; - position: relative; - - &::before { - position: absolute; - content: ""; - width: 20px; - height: 20px; - top: 0; - left: -2px; - mask-position: center; - mask-repeat: no-repeat; - background-color: $tertiary-content; - } - } - - .mx_SpaceRoomView_info_public::before { - mask-size: 12px; - mask-image: url("$(res)/img/globe.svg"); - } - - .mx_SpaceRoomView_info_private::before { - mask-size: 14px; - mask-image: url("$(res)/img/element-icons/lock.svg"); - } - } - .mx_SpaceRoomView_landing_infoBar_interactive { display: flex; flex-wrap: wrap; diff --git a/res/css/structures/_VideoRoomView.scss b/res/css/structures/_VideoRoomView.scss new file mode 100644 index 00000000000..3577e7b73e1 --- /dev/null +++ b/res/css/structures/_VideoRoomView.scss @@ -0,0 +1,40 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_VideoRoomView { + flex-grow: 1; + min-height: 0; + + display: flex; + flex-direction: column; + margin: $container-gap-width; + margin-right: calc($container-gap-width / 2); + + background-color: $header-panel-bg-color; + padding: 8px; + border-radius: 8px; + + .mx_AppTile { + width: auto; + height: 100%; + border: none; + } + + // While the lobby is shown, the widget needs to stay loaded but hidden in the background + .mx_VideoLobby ~ .mx_AppTile { + display: none; + } +} diff --git a/res/css/structures/_ViewSource.scss b/res/css/structures/_ViewSource.scss index f1ada65786e..3dc3e21489e 100644 --- a/res/css/structures/_ViewSource.scss +++ b/res/css/structures/_ViewSource.scss @@ -44,3 +44,7 @@ limitations under the License. .mx_ViewSource_container { max-width: calc(100% - 24px); } + +.mx_ViewSource_container .mx_CopyableText_border { + width: 100%; +} diff --git a/res/css/structures/_CreateRoom.scss b/res/css/views/context_menus/_DeviceContextMenu.scss similarity index 56% rename from res/css/structures/_CreateRoom.scss rename to res/css/views/context_menus/_DeviceContextMenu.scss index 78e6881b108..4b886279d7d 100644 --- a/res/css/structures/_CreateRoom.scss +++ b/res/css/views/context_menus/_DeviceContextMenu.scss @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +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,23 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_CreateRoom { - width: 960px; - margin-left: auto; - margin-right: auto; - color: $primary-content; -} +.mx_DeviceContextMenu { + max-width: 252px; -.mx_CreateRoom input, -.mx_CreateRoom textarea { - border-radius: 3px; - border: 1px solid $strong-input-border-color; - font-weight: 300; - font-size: $font-13px; - padding: 9px; - margin-top: 6px; -} + .mx_DeviceContextMenu_device_icon { + display: none; + } -.mx_CreateRoom_description { - width: 330px; + .mx_IconizedContextMenu_label { + padding-left: 0 !important; + } } diff --git a/res/css/views/context_menus/_IconizedContextMenu.scss b/res/css/views/context_menus/_IconizedContextMenu.scss index 36004af7418..84be4301ffc 100644 --- a/res/css/views/context_menus/_IconizedContextMenu.scss +++ b/res/css/views/context_menus/_IconizedContextMenu.scss @@ -25,6 +25,11 @@ limitations under the License. padding-right: 20px; } + .mx_IconizedContextMenu_optionList_label { + font-size: $font-15px; + font-weight: $font-semi-bold; + } + // the notFirst class is for cases where the optionList might be under a header of sorts. &:nth-child(n + 2), .mx_IconizedContextMenu_optionList_notFirst { // This is a bit of a hack when we could just use a simple border-top property, diff --git a/res/css/views/context_menus/_MessageContextMenu.scss b/res/css/views/context_menus/_MessageContextMenu.scss index e743619f8fd..b92ce10d355 100644 --- a/res/css/views/context_menus/_MessageContextMenu.scss +++ b/res/css/views/context_menus/_MessageContextMenu.scss @@ -90,6 +90,22 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/room/pin.svg'); } + .mx_MessageContextMenu_iconCopy::before { + mask-image: url($copy-button-url); + } + + .mx_MessageContextMenu_iconEdit::before { + mask-image: url('$(res)/img/element-icons/room/message-bar/edit.svg'); + } + + .mx_MessageContextMenu_iconReply::before { + mask-image: url('$(res)/img/element-icons/room/message-bar/reply.svg'); + } + + .mx_MessageContextMenu_iconReact::before { + mask-image: url('$(res)/img/element-icons/room/message-bar/emoji.svg'); + } + .mx_MessageContextMenu_iconViewInRoom::before { mask-image: url('$(res)/img/element-icons/view-in-room.svg'); } diff --git a/res/css/views/dialogs/_DeactivateAccountDialog.scss b/res/css/views/dialogs/_DeactivateAccountDialog.scss index 192917b2d00..8941afaf39c 100644 --- a/res/css/views/dialogs/_DeactivateAccountDialog.scss +++ b/res/css/views/dialogs/_DeactivateAccountDialog.scss @@ -18,10 +18,6 @@ limitations under the License. margin-bottom: 30px; } -.mx_DeactivateAccountDialog .mx_DeactivateAccountDialog_input_section { - margin-top: 60px; -} - .mx_DeactivateAccountDialog .mx_DeactivateAccountDialog_input_section .mx_Field { width: 300px; } diff --git a/res/css/views/dialogs/_DevtoolsDialog.scss b/res/css/views/dialogs/_DevtoolsDialog.scss index ca82b82ee14..b1fed0d29bd 100644 --- a/res/css/views/dialogs/_DevtoolsDialog.scss +++ b/res/css/views/dialogs/_DevtoolsDialog.scss @@ -26,7 +26,6 @@ limitations under the License. } .mx_DevTools_content { - margin: 10px 0; overflow-y: auto; height: calc(100% - 124px); // 58px for buttons + 50px for header + 8px margin around } diff --git a/res/css/views/dialogs/_ForwardDialog.scss b/res/css/views/dialogs/_ForwardDialog.scss index ad7bf9a8167..2cdec19ebfb 100644 --- a/res/css/views/dialogs/_ForwardDialog.scss +++ b/res/css/views/dialogs/_ForwardDialog.scss @@ -85,6 +85,10 @@ limitations under the License. margin-top: 24px; } + .mx_ForwardList_resultsList { + padding-right: 8px; + } + .mx_ForwardList_entry { display: flex; justify-content: space-between; diff --git a/res/css/views/dialogs/_RoomSettingsDialogBridges.scss b/res/css/views/dialogs/_RoomSettingsDialogBridges.scss index f18b4917cf1..07735ad0278 100644 --- a/res/css/views/dialogs/_RoomSettingsDialogBridges.scss +++ b/res/css/views/dialogs/_RoomSettingsDialogBridges.scss @@ -22,84 +22,99 @@ limitations under the License. margin: 0; padding: 0; } -} -.mx_RoomSettingsDialog_BridgeList li { - list-style-type: none; - padding: 5px; - margin-bottom: 8px; - border-width: 1px 1px; - border-color: $primary-hairline-color; - border-style: solid; - border-radius: 5px; + li { + list-style-type: none; - .column-icon { - float: left; - padding-right: 10px; + &.mx_RoomSettingsDialog_BridgeList_listItem { + display: flex; + flex-wrap: wrap; + gap: $spacing-8; + padding: 5px; + margin-bottom: $spacing-8; - * { + // border-style around each bridge list item + border-width: 1px 1px; + border-color: $primary-hairline-color; + border-style: solid; border-radius: 5px; - border: 1px solid $input-darker-bg-color; - } - - .noProtocolIcon { - width: 48px; - height: 48px; - background: $input-darker-bg-color; - border-radius: 5px; - } - .protocol-icon { - float: left; - margin-right: 5px; - img { - border-radius: 5px; - border-width: 1px 1px; - border-color: $primary-hairline-color; + .mx_RoomSettingsDialog_column_icon { + .mx_RoomSettingsDialog_protocolIcon, + .mx_RoomSettingsDialog_protocolIcon span, + .mx_RoomSettingsDialog_noProtocolIcon { + box-sizing: border-box; + border-radius: 5px; + border: 1px solid $input-darker-bg-color; + } + + .mx_RoomSettingsDialog_noProtocolIcon, + .mx_RoomSettingsDialog_protocolIcon img { + border-radius: 5px; + } + + .mx_RoomSettingsDialog_noProtocolIcon { + width: 48px; + height: 48px; + background: $input-darker-bg-color; + } + + .mx_RoomSettingsDialog_protocolIcon { + img { + border-width: 1px 1px; + border-color: $primary-hairline-color; + } + + span { + /* Correct letter placement */ + left: auto; + } + } } - span { - /* Correct letter placement */ - left: auto; - } - } - } - - .column-data { - display: inline-block; - width: 85%; - - > h3 { - margin-top: 0px; - margin-bottom: 0px; - font-size: 16pt; - color: $primary-content; - } - - > * { - margin-top: 4px; - margin-bottom: 0; - } - - .workspace-channel-details { - color: $primary-content; - font-weight: 600; - - .channel { - margin-left: 5px; - } - } - .metadata { - color: $muted-fg-color; - margin-bottom: 0; - overflow-y: visible; - text-overflow: ellipsis; - white-space: normal; - padding: 0; - - > li { - padding: 0; - border: 0; + .mx_RoomSettingsDialog_column_data { + display: inline-block; + width: 85%; + + .mx_RoomSettingsDialog_column_data_details, + .mx_RoomSettingsDialog_column_data_metadata, + .mx_RoomSettingsDialog_column_data_metadata li, + .mx_RoomSettingsDialog_column_data_protocolName { + margin-bottom: 0; + } + + .mx_RoomSettingsDialog_column_data_details, + .mx_RoomSettingsDialog_column_data_metadata { + margin-top: $spacing-4; + } + + .mx_RoomSettingsDialog_column_data_metadata li { + margin-top: $spacing-8; + } + + .mx_RoomSettingsDialog_column_data_protocolName { + margin-top: 0; + font-size: 16pt; + color: $primary-content; + } + + .mx_RoomSettingsDialog_workspace_channel_details { + color: $primary-content; + font-weight: $font-semi-bold; + + .mx_RoomSettingsDialog_channel { + margin-inline-start: 5px; + } + } + + .mx_RoomSettingsDialog_metadata { + color: $muted-fg-color; + margin-bottom: 0; + overflow-y: visible; + text-overflow: ellipsis; + white-space: normal; + padding: 0; + } } } } diff --git a/res/css/views/dialogs/_TabbedIntegrationManagerDialog.scss b/res/css/views/dialogs/_TabbedIntegrationManagerDialog.scss deleted file mode 100644 index 6385dd76f52..00000000000 --- a/res/css/views/dialogs/_TabbedIntegrationManagerDialog.scss +++ /dev/null @@ -1,62 +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_TabbedIntegrationManagerDialog .mx_Dialog { - width: 60%; - height: 70%; - overflow: hidden; - padding: 0; - max-width: initial; - max-height: initial; - position: relative; -} - -.mx_TabbedIntegrationManagerDialog_container { - // Full size of the dialog, whatever it is - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - - .mx_TabbedIntegrationManagerDialog_currentManager { - width: 100%; - height: 100%; - border-top: 1px solid $accent; - - iframe { - background-color: #fff; - border: 0; - width: 100%; - height: 100%; - } - } -} - -.mx_TabbedIntegrationManagerDialog_tab { - display: inline-block; - border: 1px solid $accent; - border-bottom: 0; - border-top-left-radius: 3px; - border-top-right-radius: 3px; - padding: 10px 8px; - margin-right: 5px; -} - -.mx_TabbedIntegrationManagerDialog_currentTab { - background-color: $accent; - color: $accent-fg-color; -} diff --git a/res/css/views/elements/_CopyableText.scss b/res/css/views/elements/_CopyableText.scss index a08306b66a4..ceafd422730 100644 --- a/res/css/views/elements/_CopyableText.scss +++ b/res/css/views/elements/_CopyableText.scss @@ -18,14 +18,17 @@ limitations under the License. .mx_CopyableText { 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_CopyableText_border { + border-radius: 5px; + border: solid 1px $light-fg-color; + margin-bottom: 10px; + margin-top: 10px; + padding: 10px; + } + .mx_CopyableText_copyButton { flex-shrink: 0; width: 20px; diff --git a/res/css/views/elements/_FacePile.scss b/res/css/views/elements/_FacePile.scss index 3e83446b0ee..e40695fcf14 100644 --- a/res/css/views/elements/_FacePile.scss +++ b/res/css/views/elements/_FacePile.scss @@ -15,12 +15,16 @@ limitations under the License. */ .mx_FacePile { + display: flex; + align-items: center; + .mx_FacePile_faces { display: inline-flex; flex-direction: row-reverse; vertical-align: middle; - > .mx_FacePile_face + .mx_FacePile_face { + // Overlap the children + > * + * { margin-right: -8px; } diff --git a/res/css/views/elements/_Pill.scss b/res/css/views/elements/_Pill.scss new file mode 100644 index 00000000000..15511601ffe --- /dev/null +++ b/res/css/views/elements/_Pill.scss @@ -0,0 +1,63 @@ +/* +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_Pill { + padding: $font-1px 0.4em $font-1px 0; + line-height: $font-17px; + border-radius: $font-16px; + vertical-align: text-top; + display: inline-flex; + align-items: center; + + cursor: pointer; + + color: $accent-fg-color !important; // To override .markdown-body + background-color: $pill-bg-color !important; // To override .markdown-body + + &.mx_UserPill_me, + &.mx_AtRoomPill { + background-color: $alert !important; // To override .markdown-body + } + + &:hover { + background-color: $pill-hover-bg-color !important; // To override .markdown-body + } + + &.mx_UserPill_me:hover { + background-color: #ff6b75 !important; // To override .markdown-body | same on both themes + } + + // We don't want to indicate clickability + &.mx_AtRoomPill:hover { + background-color: $alert !important; // To override .markdown-body + cursor: unset; + } + + .mx_BaseAvatar { + position: relative; + display: inline-flex; + align-items: center; + border-radius: 10rem; + margin-right: 0.24rem; + } + + a& { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + text-decoration: none !important; // To override .markdown-body + } +} diff --git a/res/css/views/elements/_RichText.scss b/res/css/views/elements/_RichText.scss index 4615f5da23f..4f0b532c6b4 100644 --- a/res/css/views/elements/_RichText.scss +++ b/res/css/views/elements/_RichText.scss @@ -2,86 +2,6 @@ // naming scheme; it's completely unclear where or how they're being used // --Matthew -.mx_UserPill, -.mx_RoomPill, -.mx_AtRoomPill { - display: inline-flex; - align-items: center; - vertical-align: middle; - border-radius: $font-16px; - line-height: $font-15px; - padding-left: 0; -} - -a.mx_Pill { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - max-width: 100%; -} - -.mx_Pill { - padding: $font-1px; - padding-right: 0.4em; - vertical-align: text-top; - line-height: $font-17px; -} - -/* More specific to override `.markdown-body a` text-decoration */ -.mx_EventTile_content .markdown-body a.mx_Pill { - text-decoration: none; -} - -/* More specific to override `.markdown-body a` color */ -.mx_EventTile_content .markdown-body a.mx_UserPill, -.mx_UserPill { - color: $primary-content; - background-color: $other-user-pill-bg-color; -} - -.mx_UserPill_selected { - background-color: $accent !important; -} - -/* More specific to override `.markdown-body a` color */ -.mx_EventTile_highlight .mx_EventTile_content .markdown-body a.mx_UserPill_me, -.mx_EventTile_content .markdown-body a.mx_AtRoomPill, -.mx_EventTile_content .mx_AtRoomPill, -.mx_MessageComposer_input .mx_AtRoomPill { - color: $accent-fg-color; - background-color: $alert; -} - -/* More specific to override `.markdown-body a` color */ -.mx_EventTile_content .markdown-body a.mx_RoomPill, -.mx_RoomPill { - color: $accent-fg-color; - background-color: $rte-room-pill-color; -} - -.mx_EventTile_body .mx_UserPill, -.mx_EventTile_body .mx_RoomPill { - cursor: pointer; -} - -.mx_UserPill .mx_BaseAvatar, -.mx_RoomPill .mx_BaseAvatar, -.mx_AtRoomPill .mx_BaseAvatar { - position: relative; - display: inline-flex; - align-items: center; - border-radius: 10rem; - margin-right: 0.24rem; - pointer-events: none; -} - -.mx_Emoji { - // Should be 1.8rem for our default 1.4rem message bodies, - // and scale with the size of the surrounding text - font-size: calc(18 / 14 * 1em); - vertical-align: bottom; -} - .mx_Markdown_BOLD { font-weight: bold; } @@ -115,3 +35,10 @@ a.mx_Pill { .mx_Markdown_STRIKETHROUGH { text-decoration: line-through; } + +.mx_Emoji { + // Should be 1.8rem for our default message bodies, and scale with the + // surrounding text + font-size: max($font-18px, 1em); + vertical-align: bottom; +} diff --git a/res/css/views/elements/_SettingsFlag.scss b/res/css/views/elements/_SettingsFlag.scss index 533487d98cf..c6f4cf6ec5c 100644 --- a/res/css/views/elements/_SettingsFlag.scss +++ b/res/css/views/elements/_SettingsFlag.scss @@ -41,4 +41,10 @@ limitations under the License. font-size: $font-12px; line-height: $font-15px; color: $secondary-content; + + // Support code/pre elements in settings flag descriptions + pre, code { + font-family: $monospace-font-family !important; + background-color: $rte-code-bg-color; + } } diff --git a/res/css/views/elements/_TagComposer.scss b/res/css/views/elements/_TagComposer.scss index f5bdb8d2d58..7efe83281f5 100644 --- a/res/css/views/elements/_TagComposer.scss +++ b/res/css/views/elements/_TagComposer.scss @@ -29,7 +29,9 @@ limitations under the License. margin-left: 16px; // distance from } - .mx_Field, .mx_Field input, .mx_AccessibleButton { + .mx_Field, + .mx_Field input, + .mx_AccessibleButton { // So they look related to each other by feeling the same border-radius: 8px; } @@ -39,39 +41,48 @@ limitations under the License. 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_Tag { + margin-right: 12px; + margin-top: 12px; + } +} - .mx_AccessibleButton { - background-image: url('$(res)/img/subtract.svg'); - width: 16px; - height: 16px; - margin-left: 8px; - display: inline-block; +.mx_Tag { + + font-size: $font-15px; + + display: inline-flex; + align-items: center; + + gap: 8px; + padding: 8px; + border-radius: 8px; + + color: $primary-content; + background: $quinary-content; + + >svg:first-child { + width: 1em; + color: $secondary-content; + transform: scale(1.25); + transform-origin: center; + } + + .mx_Tag_delete { + border-radius: 50%; + text-align: center; + width: 1.066666em; // 16px; + height: 1.066666em; + line-height: 1em; + color: $secondary-content; + background: $system; + position: relative; + + svg { + width: .5em; vertical-align: middle; - cursor: pointer; } } } diff --git a/res/css/views/messages/_MLocationBody.scss b/res/css/views/messages/_MLocationBody.scss index 9c7e2767d74..adca4ae4ba6 100644 --- a/res/css/views/messages/_MLocationBody.scss +++ b/res/css/views/messages/_MLocationBody.scss @@ -15,26 +15,26 @@ limitations under the License. */ .mx_MLocationBody { + max-width: 100%; + .mx_MLocationBody_map { + max-width: 100%; width: 450px; height: 300px; z-index: 0; // keeps the entire map under the message action bar border-radius: $timeline-image-border-radius; + cursor: pointer; } } /* In the timeline, we fit the width of the container */ .mx_EventTile_line .mx_MLocationBody .mx_MLocationBody_map { - width: 100%; max-width: 450px; + width: 100%; } -.mx_EventTile[data-layout="bubble"] .mx_EventTile_line .mx_MLocationBody { +.mx_EventTile[data-layout="bubble"] .mx_EventTile_line .mx_MLocationBody .mx_MLocationBody_map { max-width: 100%; - - .mx_MLocationBody_map { - max-width: 100%; - width: 450px; - } + width: 450px; } diff --git a/res/css/views/messages/_MStickerBody.scss b/res/css/views/messages/_MStickerBody.scss index 165dcd8208c..12cd7c6f39b 100644 --- a/res/css/views/messages/_MStickerBody.scss +++ b/res/css/views/messages/_MStickerBody.scss @@ -33,3 +33,10 @@ limitations under the License. align-items: center; justify-content: center; } + +.mx_MStickerBody_placeholder { + // centering + position: absolute; + left: calc(50% - 40px); + top: calc(50% - 40px); +} diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index 75318757a73..e270a8512fa 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -28,8 +28,9 @@ limitations under the License. top: -32px; right: 8px; user-select: none; - // Ensure the action bar appears above over things, like the read marker. - z-index: 1; + // Ensure the action bar appears above other things like the read marker + // and sender avatar (for small screens) + z-index: 10; // Adds a previous event safe area so that you can't accidentally hover the // previous event while trying to mouse into the action bar or from the diff --git a/res/css/views/right_panel/_ThreadPanel.scss b/res/css/views/right_panel/_ThreadPanel.scss index 4b4384f1d49..9947a7575f0 100644 --- a/res/css/views/right_panel/_ThreadPanel.scss +++ b/res/css/views/right_panel/_ThreadPanel.scss @@ -20,10 +20,6 @@ limitations under the License. height: 100px; overflow: visible; - &:not(.mx_ThreadView).mx_BaseCard { - padding-right: 2px; - } - .mx_BaseCard_header { margin-bottom: 12px; @@ -108,18 +104,42 @@ limitations under the License. } } - .mx_AutoHideScrollbar { + .mx_AutoHideScrollbar, + .mx_RoomView_messagePanelSpinner { background-color: $background; border-radius: 8px; - width: calc(100% - 24px); - padding-right: 18px; + padding-inline-end: 0; + overflow-y: scroll; // set gap between the thread tile and the right border + height: 100%; + } + + // Override _GroupLayout.scss for the thread panel + .mx_GroupLayout { + .mx_EventTile { + .mx_MessageActionBar { + right: 0; + top: -36px; // 2px above EventTile + z-index: 10; // See _EventTile.scss + } + + &[data-shape=ThreadsList] { + > .mx_DisambiguatedProfile { + margin-inline-start: 0; + } + + .mx_MessageTimestamp { + position: initial; + width: auto; + } + + .mx_EventTile_line { + padding-bottom: 0; // Override mx_EventTile_line on _GroupLayout.scss + } + } + } } &.mx_ThreadView .mx_ThreadView_timelinePanelWrapper { - /* the scrollbar is 8px wide, and we want a 12px gap with the side of the - panel. Hence the magic number, 8+4=12 */ - width: calc(100% + 6px); - padding-right: 4px; position: relative; min-height: 0; // don't displace the composer flex-grow: 1; @@ -129,9 +149,15 @@ limitations under the License. } } + .mx_RoomView_messagePanel { // To avoid the rule from being applied to .mx_ThreadPanel_empty + .mx_RoomView_messageListWrapper { + width: calc(100% + 6px); // 8px - 2px + } + } + .mx_RoomView_MessageList { - padding-left: 12px; - padding-right: 0; + padding-inline-start: $spacing-8; + padding-inline-end: $spacing-8; content-visibility: visible; } @@ -140,7 +166,7 @@ limitations under the License. // Account for scrollbar when hovering padding-top: 0; - .mx_ThreadInfo { + .mx_ThreadSummary { position: relative; padding-right: 11px; @@ -256,29 +282,15 @@ limitations under the License. } .mx_ThreadPanel_replies { - margin-top: 8px; -} + margin-top: $spacing-8; + display: flex; + align-items: center; + position: relative; -.mx_ThreadPanel_repliesSummary { - &::before { - content: ""; - mask-image: url('$(res)/img/element-icons/thread-summary.svg'); - mask-position: center; - display: inline-block; - height: 18px; - min-width: 18px; - background-color: currentColor; - mask-repeat: no-repeat; - mask-size: contain; - margin-right: 8px; - vertical-align: middle; + .mx_ThreadPanel_ThreadsAmount { + @mixin ThreadsAmount; + font-size: $font-12px; // Same font size as the counter on the main panel } - - color: $secondary-content; - font-weight: 600; - float: left; - margin-right: 12px; - font-size: $font-12px; } .mx_ThreadPanel_viewInRoom::before { @@ -300,7 +312,6 @@ limitations under the License. top: 0; bottom: 0; left: 0; - right: 6px; padding: 20px; h2 { diff --git a/res/css/views/right_panel/_TimelineCard.scss b/res/css/views/right_panel/_TimelineCard.scss index 447933a39f9..5a121a8f61c 100644 --- a/res/css/views/right_panel/_TimelineCard.scss +++ b/res/css/views/right_panel/_TimelineCard.scss @@ -42,11 +42,6 @@ limitations under the License. border-radius: 8px; } - .mx_AutoHideScrollbar { - padding-right: 10px; - width: calc(100% - 10px); - } - .mx_NewRoomIntro { margin-left: 36px; } @@ -69,7 +64,7 @@ limitations under the License. margin-right: 8px; } - .mx_ThreadInfo { + .mx_ThreadSummary { margin-left: 36px; margin-right: 0; max-width: min(calc(100% - 36px), 600px); @@ -125,8 +120,8 @@ limitations under the License. padding-left: 36px; } - .mx_EventTile_readAvatars { - top: -10px; + .mx_ReadReceiptGroup { + top: -6px; } .mx_WhoIsTypingTile { diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss index 3b7a51797f9..15cf0cdc1ec 100644 --- a/res/css/views/right_panel/_UserInfo.scss +++ b/res/css/views/right_panel/_UserInfo.scss @@ -52,6 +52,10 @@ limitations under the License. .mx_UserInfo_container { padding: 8px 16px; + + .mx_UserInfo_container_verifyButton { + margin-top: $spacing-8; + } } .mx_UserInfo_separator { @@ -193,10 +197,7 @@ limitations under the License. } .mx_UserInfo_field { - cursor: pointer; - color: $accent; line-height: $font-16px; - margin: 8px 0; &.mx_UserInfo_destructive { color: $alert; @@ -228,14 +229,18 @@ limitations under the License. padding-bottom: 0; > :not(h3) { - margin-left: 8px; + margin-inline-start: $spacing-8; + display: flex; + flex-flow: column; + align-items: flex-start; + row-gap: $spacing-8; } } .mx_UserInfo_devices { .mx_UserInfo_device { display: flex; - margin: 8px 0; + margin: $spacing-8 0; &.mx_UserInfo_device_verified { .mx_UserInfo_device_trusted { @@ -250,7 +255,7 @@ limitations under the License. .mx_UserInfo_device_name { flex: 1; - margin-right: 5px; + margin: 0 5px; word-break: break-word; } } @@ -259,20 +264,16 @@ limitations under the License. .mx_E2EIcon { // don't squeeze flex: 0 0 auto; - margin: 2px 5px 0 0; + margin: 0; width: 12px; height: 12px; } .mx_UserInfo_expand { - display: flex; - margin-top: 11px; + column-gap: 5px; // cf: mx_UserInfo_device_name + margin-bottom: 11px; } } - - .mx_AccessibleButton.mx_AccessibleButton_hasKind { - padding: 8px 18px; - } } .mx_UserInfo.mx_UserInfo_smallAvatar { diff --git a/res/css/views/rooms/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss index 73ff9f048b2..c89fa6028b6 100644 --- a/res/css/views/rooms/_BasicMessageComposer.scss +++ b/res/css/views/rooms/_BasicMessageComposer.scss @@ -51,9 +51,15 @@ limitations under the License. } &.mx_BasicMessageComposer_input_shouldShowPillAvatar { - span.mx_UserPill, span.mx_RoomPill { - position: relative; + span.mx_UserPill, span.mx_RoomPill, span.mx_SpacePill { user-select: all; + position: relative; + cursor: unset; // We don't want indicate clickability + + &:hover { + // We don't want indicate clickability | To override the overriding of .markdown-body + background-color: $pill-bg-color !important; + } // avatar psuedo element &::before { @@ -72,14 +78,6 @@ 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/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss index c2992589d0f..29888908fa8 100644 --- a/res/css/views/rooms/_EventBubbleTile.scss +++ b/res/css/views/rooms/_EventBubbleTile.scss @@ -37,7 +37,7 @@ limitations under the License. margin-left: 49px; font-size: $font-14px; - .mx_ThreadInfo { + .mx_ThreadSummary { clear: both; width: fit-content; } @@ -96,7 +96,11 @@ limitations under the License. line-height: $font-18px; } - > .mx_DisambiguatedProfile { + // inside mx_RoomView_MessageList, outside of mx_ReplyTile + // (on the main panel and the chat panel with a maximized widget) + > .mx_DisambiguatedProfile, + // inside a thread, outside of mx_ReplyTile + .mx_EventTile_senderDetails > .mx_DisambiguatedProfile { position: relative; top: -2px; left: 2px; @@ -154,7 +158,7 @@ limitations under the License. &[data-self=true] { .mx_EventTile_line { - float: right; + margin-inline-start: auto; border-bottom-left-radius: var(--cornerRadius); .mx_MImageBody .mx_MImageBody_thumbnail_container, @@ -172,7 +176,7 @@ limitations under the License. margin-right: 32px; } - .mx_ThreadInfo { + .mx_ThreadSummary { float: right; margin-right: calc(-1 * var(--gutterSize)); } @@ -406,6 +410,7 @@ limitations under the License. .mx_MPollBody { width: 550px; // to prevent timestamp overlapping summary text + max-width: 100%; // prevent overflowing a reply tile .mx_MPollBody_totalVotes { // align summary text with corner timestamp @@ -447,10 +452,13 @@ limitations under the License. } } - .mx_EventTile_readAvatars { + .mx_ReadReceiptGroup { position: absolute; - right: -78px; // as close to right gutter without clipping as possible - bottom: 0; + // as close to right gutter without clipping as possible + right: -78px; + // (EventTileLine.line-height - ReadReceiptGroup.height) / 2 + // this centers the ReadReceiptGroup if we’ve got a single line + bottom: calc(($font-18px - 24px) / 2); top: auto; } @@ -559,6 +567,17 @@ limitations under the License. padding-top: 0; } + .mx_EventTile { + &.mx_EventTile_info { + .mx_EventTile_line { + // Avoid overflow of event info by cancelling width settings + width: 100%; + min-width: 0; + max-width: 100%; + } + } + } + &::after { content: ""; clear: both; @@ -582,10 +601,10 @@ limitations under the License. margin-right: 0; .mx_MessageActionBar { - right: 127px; // align with that of right-column bubbles + right: 48px; // align with that of right-column bubbles } - .mx_EventTile_readAvatars { + .mx_ReadReceiptGroup { right: -18px; // match alignment to RRs of chat bubbles } diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 7d2fb2817d5..6f2fb704983 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -16,26 +16,25 @@ limitations under the License. */ $left-gutter: 64px; +$threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss .mx_EventTile { flex-shrink: 0; .mx_EventTile_receiptSent, .mx_EventTile_receiptSending { - // Give it some dimensions so the tooltip can position properly + position: relative; display: inline-block; - width: 14px; - height: 14px; - // We don't use `position: relative` on the element because then it won't line - // up with the other read receipts + width: 16px; + height: 16px; &::before { background-color: $tertiary-content; mask-repeat: no-repeat; mask-position: center; - mask-size: 14px; - width: 14px; - height: 14px; + mask-size: 16px; + width: 16px; + height: 16px; content: ''; position: absolute; top: 0; @@ -79,7 +78,7 @@ $left-gutter: 64px; background-color: $alert; } - .mx_ThreadInfo, + .mx_ThreadSummary, .mx_ThreadSummaryIcon { margin-left: 64px; } @@ -223,12 +222,6 @@ $left-gutter: 64px; overflow-y: hidden; } - &.mx_EventTile_selected .mx_EventTile_line, - &:hover .mx_EventTile_line { - border-top-left-radius: 4px; - border-bottom-left-radius: 4px; - } - &:hover.mx_EventTile_verified .mx_EventTile_line { box-shadow: inset calc(50px + $selected-message-border-width) 0 0 -50px $e2e-verified-color; } @@ -312,7 +305,7 @@ $left-gutter: 64px; .mx_RoomView_timeline_rr_enabled { .mx_EventTile[data-layout=group] { - .mx_ThreadInfo, + .mx_ThreadSummary, .mx_ThreadSummaryIcon, .mx_EventTile_line { /* ideally should be 100px, but 95px gives us a max thumbnail size of 800x600, which is nice */ @@ -320,7 +313,7 @@ $left-gutter: 64px; min-height: $font-14px; } - .mx_ThreadInfo { + .mx_ThreadSummary { max-width: min(calc(100% - $left-gutter - 110px), 600px); // leave space on both left & right gutters } } @@ -355,36 +348,6 @@ $left-gutter: 64px; } } -.mx_EventTile_readAvatars { - position: relative; - display: inline-block; - width: 14px; - height: 14px; - // This aligns the avatar with the last line of the - // message. We want to move it one line up - 2.2rem - top: -2.2rem; - user-select: none; - z-index: 1; -} - -.mx_EventTile_readAvatars .mx_BaseAvatar { - position: absolute; - display: inline-block; - height: $font-14px; - width: $font-14px; - - will-change: left, top; - transition: - left var(--transition-short) ease-out, - top var(--transition-standard) ease-out; -} - -.mx_EventTile_readAvatarRemainder { - color: $event-timestamp-color; - font-size: $font-11px; - position: absolute; -} - .mx_EventTile_bigEmoji { font-size: 48px; line-height: 57px; @@ -694,6 +657,16 @@ $left-gutter: 64px; visibility: visible; } +// Inverse of the above to *disable* the animation on any indicators. This approach +// is less pretty, but is easier to target because otherwise we need to define the +// animation for when it's shown which means duplicating the style definition in +// multiple places. +.mx_EventTile:not(:hover):not(.mx_EventTile_actionBarFocused):not([data-whatinput='keyboard'] :focus-within):not(.focus-visible:focus-within) { + .mx_MessageActionBar .mx_Indicator { + animation: none; + } +} + @media only screen and (max-width: 480px) { .mx_EventTile_line, @@ -708,17 +681,11 @@ $left-gutter: 64px; } } +.mx_ThreadPanel_replies::before, .mx_ThreadSummaryIcon::before, -.mx_ThreadInfo::before { - content: ""; - display: inline-block; - mask-image: url('$(res)/img/element-icons/thread-summary.svg'); - mask-position: center; - height: 18px; - min-width: 18px; +.mx_ThreadSummary::before { + @mixin ThreadInfoIcon; background-color: $secondary-content !important; - mask-repeat: no-repeat; - mask-size: contain; } .mx_ThreadSummaryIcon { @@ -735,137 +702,42 @@ $left-gutter: 64px; } } -.mx_ThreadInfo { - min-width: 267px; - max-width: min(calc(100% - $left-gutter), 600px); // leave space on both left & right gutters - width: fit-content; - height: 40px; - position: relative; - background-color: $system; - padding-left: 12px; - display: flex; - align-items: center; - border-radius: 8px; - padding-right: 16px; - margin-top: 8px; - font-size: $font-12px; - color: $secondary-content; - box-sizing: border-box; - justify-content: flex-start; - clear: both; - overflow: hidden; - border: 1px solid $system; // always render a border so the hover effect doesn't require a re-layout - - .mx_ThreadInfo_chevron { - position: absolute; - top: 0; - right: 0; - bottom: 0; - width: 60px; - box-sizing: border-box; - // XXX: We use `$system-transparent` instead of `transparent` to work around a Safari <15.4 bug - background: linear-gradient(270deg, $system 50%, $system-transparent 100%); - - opacity: 0; - transform: translateX(60px); - transition: all .1s ease-in-out; - - &::before { - content: ''; - position: absolute; - top: 50%; - right: 12px; - transform: translateY(-50%); - width: 12px; - height: 12px; - mask-image: url('$(res)/img/compound/chevron-right-12px.svg'); - mask-position: center; - mask-size: contain; - mask-repeat: no-repeat; - background-color: $secondary-content; - } - } - - &:hover, - &:focus { - cursor: pointer; - border-color: $quinary-content; - - .mx_ThreadInfo_chevron { - opacity: 1; - transform: translateX(0); - } - } - - &::before { - align-self: center; // v-align the threads icon - } -} - -.mx_MessagePanel_narrow .mx_ThreadInfo { +.mx_MessagePanel_narrow .mx_ThreadSummary { min-width: initial; - max-width: initial; + max-width: 100%; // prevent overflow width: initial; } -$threadInfoLineHeight: calc(2 * $font-12px); - -.mx_ThreadInfo_sender { - font-weight: $font-semi-bold; - line-height: $threadInfoLineHeight; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; -} - -.mx_ThreadInfo_content { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - margin-left: 4px; - font-size: $font-12px; - line-height: $threadInfoLineHeight; - color: $secondary-content; - flex: 1; -} - -.mx_ThreadInfo_avatar { - float: left; - margin-right: 8px; -} - -.mx_ThreadInfo_threads-amount { - font-weight: $font-semi-bold; - position: relative; - padding: 0 12px 0 8px; - white-space: nowrap; - line-height: $threadInfoLineHeight; -} - .mx_EventTile[data-shape=ThreadsList] { - --topOffset: 20px; - --leftOffset: 46px; + --topOffset: $spacing-12; + --leftOffset: 48px; $borderRadius: 8px; + $padding: $spacing-8; + $hrHeight: 1px; - margin: var(--topOffset) 16px var(--topOffset) 0; + margin: calc(var(--topOffset) + $hrHeight) 0 var(--topOffset); // include the height of horizontal line + padding: $padding $spacing-24 $padding $padding; border-radius: $borderRadius; display: flex; flex-flow: wrap; align-items: center; - &:hover { + &:hover, + // To cancel "&.mx_EventTile:hover .mx_EventTile_line" + &:not([data-layout=bubble]):hover .mx_EventTile_line { background-color: $system; } &::after { content: ""; position: absolute; - left: var(--leftOffset); - right: 0; - height: 1px; - bottom: calc(-1 * var(--topOffset)); + left: calc(var(--leftOffset) + $padding); + right: $spacing-24; // 24px: 32px - 8px (right padding) + height: $hrHeight; + bottom: calc(-1 * var(--topOffset) - $hrHeight); // exclude the height of horizontal line background-color: $quinary-content; + pointer-events: none; // disable the message action bar on hover } &::before { @@ -891,22 +763,15 @@ $threadInfoLineHeight: calc(2 * $font-12px); margin-top: 0; } - padding-top: 0; - .mx_EventTile_avatar { - top: 0; - left: 0; + top: $padding; + left: $padding; } .mx_DisambiguatedProfile { - margin-left: var(--leftOffset) !important; - flex: 1; margin-right: 12px; - display: inline-flex; - // not a fan of the magic number here, but I just tweaked - // the hardcoded value of the current implementation - max-width: calc(100% - 96px); + flex: 1; } .mx_DisambiguatedProfile_displayName, @@ -928,14 +793,17 @@ $threadInfoLineHeight: calc(2 * $font-12px); .mx_EventTile_line { width: 100%; box-sizing: border-box; - padding-left: var(--leftOffset) !important; border-radius: $borderRadius !important; // override 4px } + .mx_DisambiguatedProfile, + .mx_EventTile_line { + padding-inline-start: var(--leftOffset); + } + .mx_MessageTimestamp { - position: initial !important; max-width: 80px; - width: auto !important; + width: auto; } } @@ -958,6 +826,8 @@ $threadInfoLineHeight: calc(2 * $font-12px); flex-direction: column; .mx_EventTile_line { + padding-top: 2px; + padding-bottom: 2px; padding-left: 0; order: 10 !important; } @@ -971,10 +841,16 @@ $threadInfoLineHeight: calc(2 * $font-12px); padding-left: 0; padding-right: 0; } - } - .mx_EventTile:not([data-layout=bubble]) { - padding-top: 14px; // due to layout differences, this odd number matches the 18px padding-top of main tl events + .mx_ReplyChain { + .mx_MLocationBody { + margin-top: 6px; // See: https://github.com/matrix-org/matrix-react-sdk/pull/8442 + } + } + + &:not([data-layout=bubble]) { + padding-top: $spacing-16; + } } .mx_EventTile[data-layout=bubble] { @@ -1006,7 +882,7 @@ $threadInfoLineHeight: calc(2 * $font-12px); } .mx_EventTile[data-layout=group] { - $spacing-start: 48px; + $spacing-start: 56px; // 56px: 64px - 8px (padding) width: 100%; .mx_EventTile_content, @@ -1014,6 +890,7 @@ $threadInfoLineHeight: calc(2 * $font-12px); .mx_RedactedBody, .mx_UnknownBody, .mx_MPollBody, + .mx_MLocationBody, .mx_ReplyChain_wrapper, .mx_ReactionsRow { margin-left: $spacing-start; @@ -1027,15 +904,22 @@ $threadInfoLineHeight: calc(2 * $font-12px); } } + .mx_ReplyChain_wrapper { + .mx_MLocationBody { + margin-inline-start: 0; + margin-inline-end: 0; + } + } + .mx_MessageTimestamp { - top: 2px !important; - width: auto; + top: 2px; // Align with mx_EventTile_content } .mx_EventTile_senderDetails { display: flex; align-items: center; - gap: calc(14px + $selected-message-border-width); + gap: $spacing-16; // gap between the avatar and the sender ID + padding-inline-start: $spacing-8; a { flex: 1; @@ -1078,4 +962,13 @@ $threadInfoLineHeight: calc(2 * $font-12px); padding-right: 11px; // align with right edge of input margin-right: 0; // align with right edge of background } + + .mx_GroupLayout { + .mx_EventTile { + .mx_EventTile_line { + padding-top: 2px; + padding-bottom: 2px; + } + } + } } diff --git a/res/css/views/rooms/_GroupLayout.scss b/res/css/views/rooms/_GroupLayout.scss index c054256b6a6..b1d6e8b535b 100644 --- a/res/css/views/rooms/_GroupLayout.scss +++ b/res/css/views/rooms/_GroupLayout.scss @@ -97,7 +97,7 @@ $left-gutter: 64px; top: 3px; } - .mx_EventTile_readAvatars { + .mx_ReadReceiptGroup { // This aligns the avatar with the last line of the // message. We want to move it one line up - 2rem top: -2rem; diff --git a/res/css/views/rooms/_IRCLayout.scss b/res/css/views/rooms/_IRCLayout.scss index b0401a447ca..c09ee476767 100644 --- a/res/css/views/rooms/_IRCLayout.scss +++ b/res/css/views/rooms/_IRCLayout.scss @@ -40,7 +40,7 @@ $irc-line-height: $font-18px; margin-right: $right-padding; } - .mx_ThreadInfo { + .mx_ThreadSummary { margin-right: 0; margin-left: 0; } @@ -49,8 +49,8 @@ $irc-line-height: $font-18px; order: 5; flex-shrink: 0; - .mx_EventTile_readAvatars { - top: 0.2rem; // ($irc-line-height - avatar height) / 2 + .mx_ReadReceiptGroup { + top: -0.3rem; // ($irc-line-height - avatar height) / 2 } } diff --git a/res/css/views/rooms/_ReadReceiptGroup.scss b/res/css/views/rooms/_ReadReceiptGroup.scss new file mode 100644 index 00000000000..fe40b1263f3 --- /dev/null +++ b/res/css/views/rooms/_ReadReceiptGroup.scss @@ -0,0 +1,146 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_ReadReceiptGroup { + position: relative; + display: inline-block; + // This aligns the avatar with the last line of the + // message. We want to move it one line up + // See .mx_GroupLayout .mx_EventTile .mx_EventTile_line in _GroupLayout.scss + top: calc(-$font-22px - 3px); + user-select: none; + z-index: 1; + + .mx_ReadReceiptGroup_button { + display: inline-flex; + flex-direction: row; + height: 16px; + padding: 4px; + border-radius: 6px; + + &.mx_AccessibleButton { + &:hover { + background: $event-selected-color; + } + } + } + + .mx_ReadReceiptGroup_remainder { + color: $secondary-content; + font-size: $font-11px; + line-height: $font-16px; + margin-right: 4px; + } + + .mx_ReadReceiptGroup_container { + position: relative; + display: block; + height: 100%; + + .mx_BaseAvatar { + position: absolute; + display: inline-block; + height: 14px; + width: 14px; + border: 1px solid $background; + border-radius: 100%; + + will-change: left, top; + transition: + left var(--transition-short) ease-out, + top var(--transition-standard) ease-out; + } + } +} + +.mx_ReadReceiptGroup_popup { + max-height: 300px; + width: 220px; + border-radius: 8px; + display: flex; + flex-direction: column; + text-align: left; + font-size: 12px; + line-height: 15px; + + right: 0; + + &.mx_ContextualMenu_top { + top: 8px; + } + + &.mx_ContextualMenu_bottom { + bottom: 8px; + } + + .mx_ReadReceiptGroup_title { + font-size: 12px; + line-height: 15px; + margin: 16px 16px 8px; + font-weight: 600; + // shouldn’t be actually focusable + outline: none; + } + + .mx_AutoHideScrollbar { + .mx_ReadReceiptGroup_person { + display: flex; + flex-direction: row; + padding: 4px; + margin: 0 12px; + border-radius: 8px; + + &:hover { + background: $menu-selected-color; + } + + &:last-child { + margin-bottom: 8px; + } + + .mx_BaseAvatar { + margin: 6px 8px; + align-self: center; + justify-self: center; + } + + .mx_ReadReceiptGroup_name { + display: flex; + flex-direction: column; + flex-grow: 1; + flex-shrink: 1; + overflow: hidden; + + p { + margin: 2px 0; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + .mx_ReadReceiptGroup_secondary { + color: $secondary-content; + } + } + } + } +} + +.mx_ReadReceiptGroup_person--tooltip { + overflow-y: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/res/css/views/rooms/_ReplyPreview.scss b/res/css/views/rooms/_ReplyPreview.scss index eb0233108b8..598371bd368 100644 --- a/res/css/views/rooms/_ReplyPreview.scss +++ b/res/css/views/rooms/_ReplyPreview.scss @@ -25,30 +25,31 @@ limitations under the License. .mx_ReplyPreview_section { border-bottom: 1px solid $primary-hairline-color; + display: flex; + flex-flow: column; + row-gap: $spacing-8; + padding: $spacing-8 $spacing-8 0 $spacing-8; .mx_ReplyPreview_header { - margin: 8px; + display: flex; + justify-content: space-between; + column-gap: 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_header_cancel { + background-color: $primary-content; + mask: url('$(res)/img/cancel.svg'); + mask-repeat: no-repeat; + mask-position: center; + mask-size: 18px; + width: 18px; + height: 18px; + min-width: 18px; + min-height: 18px; + } } } } diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss index 3eabf5e57a3..85c139402be 100644 --- a/res/css/views/rooms/_RoomHeader.scss +++ b/res/css/views/rooms/_RoomHeader.scss @@ -141,17 +141,24 @@ limitations under the License. } .mx_RoomHeader_topic { + $lineHeight: $font-16px; + $lines: 2; + flex: 1; color: $roomtopic-color; font-weight: 400; font-size: $font-13px; + line-height: $lineHeight; + max-height: calc($lineHeight * $lines); + border-bottom: 1px solid transparent; + // to align baseline of topic with room name margin: 4px 7px 0; + overflow: hidden; - text-overflow: ellipsis; - border-bottom: 1px solid transparent; - line-height: 1.2em; - max-height: 2.4em; + -webkit-line-clamp: $lines; // See: https://drafts.csswg.org/css-overflow-3/#webkit-line-clamp + -webkit-box-orient: vertical; + display: -webkit-box; } .mx_RoomHeader_avatar { @@ -210,6 +217,10 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/room/search-inset.svg'); } +.mx_RoomHeader_inviteButton::before { + mask-image: url('$(res)/img/element-icons/room/invite.svg'); +} + .mx_RoomHeader_voiceCallButton::before { mask-image: url('$(res)/img/element-icons/call/voice-call.svg'); diff --git a/res/css/views/rooms/_RoomInfoLine.scss b/res/css/views/rooms/_RoomInfoLine.scss new file mode 100644 index 00000000000..5c0aea7c0bd --- /dev/null +++ b/res/css/views/rooms/_RoomInfoLine.scss @@ -0,0 +1,58 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_RoomInfoLine { + color: $secondary-content; + display: inline-block; + + &::before { + content: ""; + display: inline-block; + height: 1.2em; + mask-position-y: center; + mask-repeat: no-repeat; + background-color: $tertiary-content; + vertical-align: text-bottom; + margin-right: 6px; + } + + &.mx_RoomInfoLine_public::before { + width: 12px; + mask-size: 12px; + mask-image: url("$(res)/img/globe.svg"); + } + + &.mx_RoomInfoLine_private::before { + width: 14px; + mask-size: 14px; + mask-image: url("$(res)/img/element-icons/lock.svg"); + } + + &.mx_RoomInfoLine_video::before { + width: 16px; + mask-size: 16px; + mask-image: url("$(res)/img/element-icons/call/video-call.svg"); + } + + .mx_RoomInfoLine_members { + color: inherit; + + &::before { + content: "·"; // visual separator + margin: 0 6px; + } + } +} diff --git a/res/css/views/rooms/_RoomPreviewCard.scss b/res/css/views/rooms/_RoomPreviewCard.scss new file mode 100644 index 00000000000..b561bf666df --- /dev/null +++ b/res/css/views/rooms/_RoomPreviewCard.scss @@ -0,0 +1,136 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_RoomPreviewCard { + padding: $spacing-32 $spacing-24 !important; // Override SpaceRoomView's default padding + margin: auto; + flex-grow: 1; + max-width: 480px; + box-sizing: border-box; + background-color: $system; + border-radius: 8px; + position: relative; + font-size: $font-14px; + + .mx_RoomPreviewCard_notice { + font-weight: $font-semi-bold; + line-height: $font-24px; + color: $primary-content; + margin-top: $spacing-24; + position: relative; + padding-left: calc(20px + $spacing-8); + + .mx_AccessibleButton_kind_link { + display: inline; + padding: 0; + font-size: inherit; + line-height: inherit; + } + + &::before { + content: ""; + position: absolute; + height: $font-24px; + width: 20px; + left: 0; + 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_RoomPreviewCard_inviter { + display: flex; + align-items: center; + margin-bottom: $spacing-20; + font-size: $font-15px; + + > div { + margin-left: $spacing-8; + + .mx_RoomPreviewCard_inviter_name { + line-height: $font-18px; + } + + .mx_RoomPreviewCard_inviter_mxid { + color: $secondary-content; + } + } + } + + .mx_RoomPreviewCard_avatar { + display: flex; + align-items: center; + + .mx_RoomAvatar_isSpaceRoom { + &.mx_BaseAvatar_image, .mx_BaseAvatar_image { + border-radius: 12px; + } + } + + .mx_RoomPreviewCard_video { + width: 50px; + height: 50px; + border-radius: calc((50px + 2 * 3px) / 2); + background-color: $accent; + border: 3px solid $system; + + position: relative; + left: calc(-50px / 4 - 3px); + + &::before { + content: ""; + background-color: $button-primary-fg-color; + position: absolute; + width: 50px; + height: 50px; + mask-size: 22px; + mask-position: center; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/element-icons/call/video-call.svg'); + } + } + } + + h1.mx_RoomPreviewCard_name { + margin: $spacing-16 0 !important; // Override SpaceRoomView's default margins + } + + .mx_RoomPreviewCard_topic { + line-height: $font-22px; + margin-top: $spacing-16; + max-height: 160px; + overflow-y: auto; + } + + .mx_FacePile { + margin-top: $spacing-20; + } + + .mx_RoomPreviewCard_joinButtons { + margin-top: $spacing-20; + display: flex; + gap: $spacing-20; + + .mx_AccessibleButton { + max-width: 200px; + padding: 14px 0; + flex-grow: 1; + } + } +} diff --git a/res/css/views/rooms/_ThreadSummary.scss b/res/css/views/rooms/_ThreadSummary.scss new file mode 100644 index 00000000000..11ff6cdbbe3 --- /dev/null +++ b/res/css/views/rooms/_ThreadSummary.scss @@ -0,0 +1,113 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +$left-gutter: 64px; // From _EventTile.scss +$threadSummaryLineHeight: calc(2 * $font-12px); + +.mx_ThreadSummary { + min-width: 267px; + max-width: min(calc(100% - $left-gutter), 600px); // leave space on both left & right gutters + width: fit-content; + height: 40px; + position: relative; + background-color: $system; + padding-left: $spacing-12; + display: flex; + align-items: center; + border-radius: 8px; + padding-right: $spacing-16; + margin-top: $spacing-8; + font-size: $font-12px; + color: $secondary-content; + box-sizing: border-box; + justify-content: flex-start; + clear: both; + overflow: hidden; + border: 1px solid $system; // always render a border so the hover effect doesn't require a re-layout + + .mx_ThreadSummary_chevron { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 60px; + box-sizing: border-box; + // XXX: We use `$system-transparent` instead of `transparent` to work around a Safari <15.4 bug + background: linear-gradient(270deg, $system 50%, $system-transparent 100%); + + opacity: 0; + transform: translateX(60px); + transition: all .1s ease-in-out; + + &::before { + content: ''; + position: absolute; + top: 50%; + right: $spacing-12; + transform: translateY(-50%); + width: 12px; + height: 12px; + mask-image: url('$(res)/img/compound/chevron-right-12px.svg'); + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background-color: $secondary-content; + } + } + + &:hover, + &:focus { + cursor: pointer; + border-color: $quinary-content; + + .mx_ThreadSummary_chevron { + opacity: 1; + transform: translateX(0); + } + } + + &::before { + align-self: center; // v-align the threads icon + } +} + +// XXX: these classes are re-used outside of the component +.mx_ThreadSummary_ThreadsAmount { + @mixin ThreadsAmount; +} + +.mx_ThreadSummary_sender { + font-weight: $font-semi-bold; + line-height: $threadSummaryLineHeight; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.mx_ThreadSummary_content { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + margin-left: $spacing-4; + font-size: $font-12px; + line-height: $threadSummaryLineHeight; + color: $secondary-content; + flex: 1; +} + +.mx_ThreadSummary_avatar { + margin-inline-end: $spacing-8; +} diff --git a/res/css/views/typography/_Heading.scss b/res/css/views/typography/_Heading.scss index 9b7ddeaef3f..84a008c18f8 100644 --- a/res/css/views/typography/_Heading.scss +++ b/res/css/views/typography/_Heading.scss @@ -37,3 +37,11 @@ limitations under the License. margin-inline: unset; margin-block: unset; } + +.mx_Heading_h4 { + font-size: $font-15px; + font-weight: $font-semi-bold; + line-height: $font-20px; + margin-inline: unset; + margin-block: unset; +} diff --git a/res/css/views/voip/CallView/_CallViewButtons.scss b/res/css/views/voip/CallView/_CallViewButtons.scss index 9305d07f3b7..4c375ee2222 100644 --- a/res/css/views/voip/CallView/_CallViewButtons.scss +++ b/res/css/views/voip/CallView/_CallViewButtons.scss @@ -1,7 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2020 - 2021 The Matrix.org Foundation C.I.C. -Copyright 2021 Šimon Brandner +Copyright 2021 - 2022 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -27,7 +27,7 @@ limitations under the License. position: absolute; display: flex; justify-content: center; - bottom: 24px; + bottom: 32px; opacity: 1; transition: opacity 0.5s; z-index: 200; // To be above _all_ feeds @@ -46,6 +46,10 @@ limitations under the License. justify-content: center; align-items: center; + position: relative; + + box-shadow: 0px 4px 4px 0px #00000026; // Same on both themes + &::before { content: ''; display: inline-block; @@ -60,6 +64,25 @@ limitations under the License. width: 24px; } + &.mx_CallViewButtons_dropdownButton { + width: 16px; + height: 16px; + + position: absolute; + right: 0; + bottom: 0; + + &::before { + width: 14px; + height: 14px; + mask-image: url('$(res)/img/element-icons/message/chevron-up.svg'); + } + + &.mx_CallViewButtons_dropdownButton_collapsed::before { + transform: rotate(180deg); + } + } + // State buttons &.mx_CallViewButtons_button_on { background-color: $call-view-button-on-background; diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index 9c9548444e4..9e34134a7d1 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020 - 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 2022 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -22,202 +23,176 @@ limitations under the License. padding-right: 8px; // XXX: PiPContainer sets pointer-events: none - should probably be set back in a better place pointer-events: initial; -} - -.mx_CallView_large { - padding-bottom: 10px; - margin: $container-gap-width; - // The left side gap is fully handled by this margin. To prohibit bleeding on webkit browser. - margin-right: calc($container-gap-width / 2); - margin-bottom: 10px; - display: flex; - flex-direction: column; - flex: 1; - - .mx_CallView_voice { - flex: 1; - } - &.mx_CallView_belowWidget { - margin-top: 0; - } -} + .mx_CallView_toast { + position: absolute; + top: 74px; -.mx_CallView_pip { - width: 320px; - padding-bottom: 8px; - background-color: $system; - box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.2); - border-radius: 8px; + padding: 4px 8px; - .mx_CallView_video_hold, - .mx_CallView_voice { - height: 180px; - } + border-radius: 4px; + z-index: 50; - .mx_CallViewButtons { - bottom: 13px; + // Same on both themes + color: white; + background-color: #17191c; } - .mx_CallViewButtons_button { - width: 34px; - height: 34px; + .mx_CallView_content_wrapper { + display: flex; + justify-content: center; - &::before { - width: 22px; - height: 22px; - } - } - - .mx_CallView_holdTransferContent { - padding-top: 10px; - padding-bottom: 25px; - } -} - -.mx_CallView_content { - position: relative; - display: flex; - justify-content: center; - border-radius: 8px; - - > .mx_VideoFeed { width: 100%; height: 100%; - &.mx_VideoFeed_voice { + overflow: hidden; + + .mx_CallView_content { + position: relative; + display: flex; + flex-direction: column; justify-content: center; align-items: center; - } - .mx_VideoFeed_video { - height: 100%; - background-color: #000; + flex: 1; + overflow: hidden; + + border-radius: 10px; + + padding: 10px; + padding-right: calc(20% + 20px); // Space for the sidebar + + background-color: $call-view-content-background; + + .mx_CallView_status { + z-index: 50; + color: $accent-fg-color; + } + + .mx_CallView_avatarsContainer { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + + div { + margin-left: 12px; + margin-right: 12px; + } + } + + .mx_CallView_holdBackground { + position: absolute; + left: 0; + right: 0; + + width: 100%; + height: 100%; + + background-repeat: no-repeat; + background-size: cover; + background-position: center; + filter: blur(20px); + + &::after { + content: ""; + display: block; + position: absolute; + width: 100%; + height: 100%; + left: 0; + right: 0; + background-color: rgba(0, 0, 0, 0.6); + } + } + + &.mx_CallView_content_hold .mx_CallView_status { + font-weight: bold; + text-align: center; + + &::before { + display: block; + margin-left: auto; + margin-right: auto; + content: ""; + width: 40px; + height: 40px; + background-image: url("$(res)/img/voip/paused.svg"); + background-position: center; + background-size: cover; + } + + .mx_CallView_pip &::before { + width: 30px; + height: 30px; + } + + .mx_AccessibleButton_hasKind { + padding: 0px; + } + } } + } + + &:not(.mx_CallView_sidebar) .mx_CallView_content { + padding: 0; + width: 100%; + height: 100%; + + .mx_VideoFeed_primary { + aspect-ratio: unset; + border: 0; - .mx_VideoFeed_mic { - left: 10px; - bottom: 10px; + width: 100%; + height: 100%; } } -} -.mx_CallView_voice { - align-items: center; - justify-content: center; - flex-direction: column; - background-color: $inverted-bg-color; -} + &.mx_CallView_pip { + width: 320px; + padding-bottom: 8px; -.mx_CallView_voice_avatarsContainer { - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - div { - margin-left: 12px; - margin-right: 12px; - } -} + border-radius: 8px; -.mx_CallView_voice .mx_CallView_holdTransferContent { - // This masks the avatar image so when it's blurred, the edge is still crisp - .mx_CallView_voice_avatarContainer { - border-radius: 2000px; - overflow: hidden; - position: relative; - } -} + background-color: $system; + box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.2); + + .mx_CallViewButtons { + bottom: 13px; -.mx_CallView_holdTransferContent { - height: 20px; - padding-top: 20px; - padding-bottom: 15px; - color: $accent-fg-color; - user-select: none; + .mx_CallViewButtons_button { + width: 34px; + height: 34px; + + &::before { + width: 22px; + height: 22px; + } + } + } - .mx_AccessibleButton_hasKind { - padding: 0px; - font-weight: bold; + .mx_CallView_content { + min-height: 180px; + } } -} -.mx_CallView_video { - width: 100%; - height: 100%; - z-index: 30; - overflow: hidden; -} + &.mx_CallView_large { + display: flex; + flex-direction: column; + align-items: center; -.mx_CallView_video_hold { - overflow: hidden; + flex: 1; - // we keep these around in the DOM: it saved wiring them up again when the call - // is resumed and keeps the container the right size - .mx_VideoFeed { - visibility: hidden; - } -} + padding-bottom: 10px; -.mx_CallView_video_holdBackground { - position: absolute; - width: 100%; - height: 100%; - left: 0; - right: 0; - background-repeat: no-repeat; - background-size: cover; - background-position: center; - filter: blur(20px); - &::after { - content: ""; - display: block; - position: absolute; - width: 100%; - height: 100%; - left: 0; - right: 0; - background-color: rgba(0, 0, 0, 0.6); + margin: $container-gap-width; + // The left side gap is fully handled by this margin. To prohibit bleeding on webkit browser. + margin-right: calc($container-gap-width / 2); + margin-bottom: 10px; } -} -.mx_CallView_video .mx_CallView_holdTransferContent { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - font-weight: bold; - color: $accent-fg-color; - text-align: center; - - &::before { - display: block; - margin-left: auto; - margin-right: auto; - content: ""; - width: 40px; - height: 40px; - background-image: url("$(res)/img/voip/paused.svg"); - background-position: center; - background-size: cover; - } - .mx_CallView_pip &::before { - width: 30px; - height: 30px; - } - .mx_AccessibleButton_hasKind { - padding: 0px; + &.mx_CallView_belowWidget { + margin-top: 0; } } - -.mx_CallView_presenting { - position: absolute; - margin-top: 18px; - padding: 4px 8px; - border-radius: 4px; - - // Same on both themes - color: white; - background-color: #17191c; -} diff --git a/res/css/views/voip/_CallViewHeader.scss b/res/css/views/voip/_CallViewHeader.scss index 358357f1343..6280da8cbb7 100644 --- a/res/css/views/voip/_CallViewHeader.scss +++ b/res/css/views/voip/_CallViewHeader.scss @@ -1,5 +1,6 @@ /* Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 2022 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,8 +20,9 @@ limitations under the License. display: flex; flex-direction: row; align-items: center; - justify-content: left; + justify-content: space-between; flex-shrink: 0; + width: 100%; &.mx_CallViewHeader_pip { cursor: pointer; @@ -43,6 +45,8 @@ limitations under the License. .mx_CallViewHeader_controls { margin-left: auto; + display: flex; + gap: 5px; } .mx_CallViewHeader_button { @@ -61,17 +65,23 @@ limitations under the License. mask-size: contain; mask-position: center; } -} -.mx_CallViewHeader_button_fullscreen { - &::before { - mask-image: url('$(res)/img/element-icons/call/fullscreen.svg'); + &.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_button_pin { + &::before { + mask-image: url('$(res)/img/element-icons/room/pin-upright.svg'); + } + } + + &.mx_CallViewHeader_button_expand { + &::before { + mask-image: url('$(res)/img/element-icons/call/expand.svg'); + } } } diff --git a/res/css/views/voip/_CallViewSidebar.scss b/res/css/views/voip/_CallViewSidebar.scss index 4871ccfe65e..351f4061f4b 100644 --- a/res/css/views/voip/_CallViewSidebar.scss +++ b/res/css/views/voip/_CallViewSidebar.scss @@ -1,5 +1,5 @@ /* -Copyright 2021 Šimon Brandner +Copyright 2021 - 2022 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,18 +16,15 @@ limitations under the License. .mx_CallViewSidebar { position: absolute; - right: 16px; - bottom: 16px; - z-index: 100; // To be above the primary feed + right: 10px; - overflow: auto; - - height: calc(100% - 32px); // Subtract the top and bottom padding width: 20%; + height: 100%; + overflow: auto; display: flex; - flex-direction: column-reverse; - justify-content: flex-start; + flex-direction: column; + justify-content: center; align-items: flex-end; gap: 12px; @@ -42,15 +39,6 @@ limitations under the License. background-color: $video-feed-secondary-background; } - - .mx_VideoFeed_video { - border-radius: 4px; - } - - .mx_VideoFeed_mic { - left: 6px; - bottom: 6px; - } } &.mx_CallViewSidebar_pipMode { diff --git a/res/css/views/voip/_VideoFeed.scss b/res/css/views/voip/_VideoFeed.scss index 29dcb5cba3c..a0ab8269c0a 100644 --- a/res/css/views/voip/_VideoFeed.scss +++ b/res/css/views/voip/_VideoFeed.scss @@ -1,5 +1,6 @@ /* -Copyright 2015, 2016, 2020 The Matrix.org Foundation C.I.C. +Copyright 2015, 2016, 2020, 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 2022 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,15 +21,32 @@ limitations under the License. box-sizing: border-box; border: transparent 2px solid; display: flex; + border-radius: 4px; + + &.mx_VideoFeed_secondary { + position: absolute; + right: 24px; + bottom: 72px; + width: 20%; + } &.mx_VideoFeed_voice { background-color: $inverted-bg-color; - aspect-ratio: 16 / 9; + + display: flex; + justify-content: center; + align-items: center; + + &:not(.mx_VideoFeed_primary) { + aspect-ratio: 16 / 9; + } } .mx_VideoFeed_video { + height: 100%; width: 100%; - background-color: transparent; + border-radius: 4px; + background-color: #000000; &.mx_VideoFeed_video_mirror { transform: scale(-1, 1); @@ -37,6 +55,8 @@ limitations under the License. .mx_VideoFeed_mic { position: absolute; + left: 6px; + bottom: 6px; display: flex; align-items: center; justify-content: center; diff --git a/res/css/views/voip/_VideoLobby.scss b/res/css/views/voip/_VideoLobby.scss new file mode 100644 index 00000000000..a708e79c90e --- /dev/null +++ b/res/css/views/voip/_VideoLobby.scss @@ -0,0 +1,174 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_VideoLobby { + min-height: 0; + flex-grow: 1; + padding: $spacing-12; + color: $video-lobby-primary-content; + background-color: $video-lobby-background; + border-radius: 8px; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $spacing-32; + + .mx_FacePile { + width: fit-content; + margin: $spacing-8 auto 0; + + .mx_FacePile_faces .mx_BaseAvatar_image { + border-color: $video-lobby-background; + } + } + + .mx_VideoLobby_preview { + position: relative; + width: 100%; + max-width: 800px; + aspect-ratio: 1.5; + background-color: $video-lobby-system; + + border-radius: 20px; + overflow: hidden; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + .mx_BaseAvatar { + margin: $spacing-20; + + // Override the explicit dimensions on the element so that this gets sized responsively + width: unset !important; + height: unset !important; + min-width: 0; + min-height: 0; + flex: 0 1 200px; + } + + video { + position: absolute; + top: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transform: scaleX(-1); // flip the image + background-color: black; + } + + .mx_VideoLobby_controls { + position: absolute; + bottom: 0; + left: 0; + right: 0; + + background-color: rgba($video-lobby-background, 0.9); + + display: flex; + justify-content: center; + gap: $spacing-24; + + .mx_VideoLobby_deviceButtonWrapper { + position: relative; + margin: 6px 0 10px; + + .mx_VideoLobby_deviceButton { + $size: 50px; + + width: $size; + height: $size; + + background-color: $video-lobby-primary-content; + border-radius: calc($size / 2); + + &::before { + content: ''; + display: inline-block; + mask-repeat: no-repeat; + mask-size: 20px; + mask-position: center; + background-color: $video-lobby-system; + height: 100%; + width: 100%; + } + + &.mx_VideoLobby_deviceButton_audio::before { + mask-image: url('$(res)/img/voip/call-view/mic-off.svg'); + } + + &.mx_VideoLobby_deviceButton_video::before { + mask-image: url('$(res)/img/voip/call-view/cam-off.svg'); + } + } + + .mx_VideoLobby_deviceListButton { + $size: 15px; + + position: absolute; + bottom: 0; + right: -2.5px; + width: $size; + height: $size; + + background-color: $video-lobby-primary-content; + border-radius: calc($size / 2); + + &::before { + content: ''; + display: inline-block; + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); + mask-size: $size; + mask-position: center; + background-color: $video-lobby-system; + height: 100%; + width: 100%; + } + } + + &.mx_VideoLobby_deviceButtonWrapper_active { + .mx_VideoLobby_deviceButton, + .mx_VideoLobby_deviceListButton { + background-color: $video-lobby-system; + + &::before { + background-color: $video-lobby-primary-content; + } + } + + .mx_VideoLobby_deviceButton { + &.mx_VideoLobby_deviceButton_audio::before { + mask-image: url('$(res)/img/voip/call-view/mic-on.svg'); + } + + &.mx_VideoLobby_deviceButton_video::before { + mask-image: url('$(res)/img/voip/call-view/cam-on.svg'); + } + } + } + } + } + } + + .mx_VideoLobby_joinButton { + padding-left: 50px; + padding-right: 50px; + } +} diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index aa95979a7db..4c5d50f9e1f 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -94,8 +94,8 @@ $roomheader-addroom-fg-color: $primary-content; // Rich-text-editor // ******************** -$rte-room-pill-color: $room-highlight-color; -$other-user-pill-bg-color: $room-highlight-color; +$pill-bg-color: $room-highlight-color; +$pill-hover-bg-color: #545a66; // ******************** // Inputs @@ -185,8 +185,13 @@ $call-view-button-on-foreground: $primary-content; $call-view-button-on-background: $system; $call-view-button-off-foreground: $system; $call-view-button-off-background: $primary-content; +$call-view-content-background: $quinary-content; $video-feed-secondary-background: $system; + +$video-lobby-system: $system; +$video-lobby-background: $background; +$video-lobby-primary-content: $primary-content; // ******************** // Location sharing diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index 981495bd131..8997538e0ab 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -27,8 +27,8 @@ $light-fg-color: $header-panel-text-secondary-color; // used for focusing form controls $focus-bg-color: $room-highlight-color; -$other-user-pill-bg-color: $room-highlight-color; -$rte-room-pill-color: $room-highlight-color; +$pill-bg-color: $room-highlight-color; +$pill-hover-bg-color: #545a66; // informational plinth $info-plinth-bg-color: $header-panel-bg-color; @@ -117,9 +117,14 @@ $call-view-button-on-foreground: $primary-content; $call-view-button-on-background: $system; $call-view-button-off-foreground: $system; $call-view-button-off-background: $primary-content; +$call-view-content-background: $quinary-content; $video-feed-secondary-background: $system; +$video-lobby-system: $system; +$video-lobby-background: $background; +$video-lobby-primary-content: $primary-content; + $roomlist-filter-active-bg-color: $panel-actions; $roomlist-bg-color: $header-panel-bg-color; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index d67b6243c7b..49f690d6a04 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -37,8 +37,6 @@ $selection-fg-color: $primary-bg-color; $focus-brightness: 105%; -$other-user-pill-bg-color: rgba(0, 0, 0, 0.1); - // informational plinth $info-plinth-bg-color: #f7f7f7; $info-plinth-fg-color: #888; @@ -117,10 +115,12 @@ $settings-subsection-fg-color: #61708b; $rte-bg-color: #e9e9e9; $rte-code-bg-color: rgba(0, 0, 0, 0.04); -$rte-room-pill-color: #aaa; $header-panel-text-primary-color: #91a1c0; +$pill-bg-color: #aaa; +$pill-hover-bg-color: #ccc; + $topleftmenu-color: #212121; $roomheader-bg-color: $primary-bg-color; $roomheader-addroom-bg-color: #91a1c0; @@ -175,9 +175,15 @@ $call-view-button-on-foreground: $secondary-content; $call-view-button-on-background: $background; $call-view-button-off-foreground: $background; $call-view-button-off-background: $secondary-content; +$call-view-content-background: #21262C; $video-feed-secondary-background: #394049; // XXX: Color from dark theme +// All of these are from dark theme +$video-lobby-system: #21262C; +$video-lobby-background: #15191E; +$video-lobby-primary-content: #FFFFFF; + $username-variant1-color: #368bd6; $username-variant2-color: #ac3ba8; $username-variant3-color: #03b381; diff --git a/res/themes/light-custom/css/_custom.scss b/res/themes/light-custom/css/_custom.scss index e2d4fef8fe1..54d3dfd15e7 100644 --- a/res/themes/light-custom/css/_custom.scss +++ b/res/themes/light-custom/css/_custom.scss @@ -142,5 +142,6 @@ $eventbubble-reply-color: var(--eventbubble-reply-color, $eventbubble-reply-colo $reaction-row-button-selected-bg-color: var(--reaction-row-button-selected-bg-color, $reaction-row-button-selected-bg-color); $menu-selected-color: var(--menu-selected-color, $menu-selected-color); -$other-user-pill-bg-color: var(--other-user-pill-bg-color, $other-user-pill-bg-color); +$pill-bg-color: var(--other-user-pill-bg-color, $pill-bg-color); +$pill-hover-bg-color: var(--other-user-pill-bg-color, $pill-hover-bg-color); $icon-button-color: var(--icon-button-color, $icon-button-color); diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 1c933a61468..bd7194e5fb4 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -147,8 +147,8 @@ $roomheader-addroom-fg-color: #5c6470; // Rich-text-editor // ******************** -$rte-room-pill-color: #aaa; -$other-user-pill-bg-color: rgba(0, 0, 0, 0.1); +$pill-bg-color: #aaa; +$pill-hover-bg-color: #ccc; $rte-bg-color: #e9e9e9; $rte-code-bg-color: rgba(0, 0, 0, 0.04); // ******************** @@ -277,9 +277,15 @@ $call-view-button-on-foreground: $secondary-content; $call-view-button-on-background: $background; $call-view-button-off-foreground: $background; $call-view-button-off-background: $secondary-content; +$call-view-content-background: #21262C; $video-feed-secondary-background: #394049; // XXX: Color from dark theme $voipcall-plinth-color: $system; + +// All of these are from dark theme +$video-lobby-system: #21262C; +$video-lobby-background: #15191E; +$video-lobby-primary-content: #FFFFFF; // ******************** // One-off colors diff --git a/scripts/fetchdep.sh b/scripts/fetchdep.sh index 5d2e65c2844..81f0784ff96 100755 --- a/scripts/fetchdep.sh +++ b/scripts/fetchdep.sh @@ -10,6 +10,9 @@ defbranch="$3" rm -r "$defrepo" || true +PR_ORG=${PR_ORG:-"matrix-org"} +PR_REPO=${PR_REPO:-"matrix-react-sdk"} + # A function that clones a branch of a repo based on the org, repo and branch clone() { org=$1 @@ -29,8 +32,7 @@ getPRInfo() { 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 + apiEndpoint="https://api.github.com/repos/$PR_ORG/$PR_REPO/pulls/$number" head=$(curl $apiEndpoint | jq -r '.head.label') fi @@ -58,7 +60,7 @@ TRY_ORG=$deforg TRY_BRANCH=${BRANCH_ARRAY[0]} if [[ "$head" == *":"* ]]; then # ... but only match that fork if it's a real fork - if [ "${BRANCH_ARRAY[0]}" != "matrix-org" ]; then + if [ "${BRANCH_ARRAY[0]}" != "$PR_ORG" ]; then TRY_ORG=${BRANCH_ARRAY[0]} fi TRY_BRANCH=${BRANCH_ARRAY[1]} @@ -66,9 +68,9 @@ fi clone ${TRY_ORG} $defrepo ${TRY_BRANCH} # Try the target branch of the push or PR. -if [ -n $GITHUB_BASE_REF ]; then +if [ -n "$GITHUB_BASE_REF" ]; then clone $deforg $defrepo $GITHUB_BASE_REF -elif [ -n $BUILDKITE_PULL_REQUEST_BASE_BRANCH ]; then +elif [ -n "$BUILDKITE_PULL_REQUEST_BASE_BRANCH" ]; then clone $deforg $defrepo $BUILDKITE_PULL_REQUEST_BASE_BRANCH fi diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000000..47814f9d418 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,14 @@ +sonar.projectKey=matrix-react-sdk +sonar.organization=matrix-org + +# Encoding of the source code. Default is default system encoding +#sonar.sourceEncoding=UTF-8 + +sonar.sources=src,res +sonar.tests=test,cypress +sonar.exclusions=__mocks__,docs + +sonar.typescript.tsconfigPath=./tsconfig.json +sonar.javascript.lcov.reportPaths=coverage/lcov.info +sonar.coverage.exclusions=test/**/*,cypress/**/* +sonar.testExecutionReportPaths=coverage/test-report.xml diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index 383d4eb29ee..b7f52d38952 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -145,6 +145,13 @@ export default abstract class BasePlatform { return false; } + /** + * Returns true if platform allows overriding native context menus + */ + public allowOverridingNativeContextMenus(): boolean { + return false; + } + /** * Returns true if the platform supports displaying * notifications, otherwise false. diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index 1d54b1adc3a..7cb0ad1db9c 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -380,11 +380,11 @@ export default class ContentMessages { const tooBigFiles = []; const okFiles = []; - for (let i = 0; i < files.length; ++i) { - if (this.isFileSizeAcceptable(files[i])) { - okFiles.push(files[i]); + for (const file of files) { + if (this.isFileSizeAcceptable(file)) { + okFiles.push(file); } else { - tooBigFiles.push(files[i]); + tooBigFiles.push(file); } } @@ -450,13 +450,7 @@ export default class ContentMessages { } public cancelUpload(promise: Promise, matrixClient: MatrixClient): void { - let upload: IUpload; - for (let i = 0; i < this.inprogress.length; ++i) { - if (this.inprogress[i].promise === promise) { - upload = this.inprogress[i]; - break; - } - } + const upload = this.inprogress.find(item => item.promise === promise); if (upload) { upload.canceled = true; matrixClient.cancelUpload(upload.promise); diff --git a/src/DecryptionFailureTracker.ts b/src/DecryptionFailureTracker.ts index 2bb522e7fe9..c56b245f259 100644 --- a/src/DecryptionFailureTracker.ts +++ b/src/DecryptionFailureTracker.ts @@ -31,18 +31,19 @@ export class DecryptionFailure { type ErrorCode = "OlmKeysNotSentError" | "OlmIndexError" | "UnknownError" | "OlmUnspecifiedError"; -type TrackingFn = (count: number, trackedErrCode: ErrorCode) => void; +type TrackingFn = (count: number, trackedErrCode: ErrorCode, rawError: string) => void; export type ErrCodeMapFn = (errcode: string) => ErrorCode; export class DecryptionFailureTracker { - private static internalInstance = new DecryptionFailureTracker((total, errorCode) => { + private static internalInstance = new DecryptionFailureTracker((total, errorCode, rawError) => { Analytics.trackEvent('E2E', 'Decryption failure', errorCode, String(total)); for (let i = 0; i < total; i++) { PosthogAnalytics.instance.trackEvent({ eventName: "Error", domain: "E2EE", name: errorCode, + context: `mxc_crypto_error_type_${rawError}`, }); } }, (errorCode) => { @@ -236,7 +237,7 @@ export class DecryptionFailureTracker { if (this.failureCounts[errorCode] > 0) { const trackedErrorCode = this.errorCodeMapFn(errorCode); - this.fn(this.failureCounts[errorCode], trackedErrorCode); + this.fn(this.failureCounts[errorCode], trackedErrorCode, errorCode); this.failureCounts[errorCode] = 0; } } diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 5a2318db176..7f92653c30b 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -27,13 +27,17 @@ import katex from 'katex'; import { AllHtmlEntities } from 'html-entities'; import { IContent } from 'matrix-js-sdk/src/models/event'; -import { _linkifyElement, _linkifyString } from './linkify-matrix'; +import { + _linkifyElement, + _linkifyString, + ELEMENT_URL_PATTERN, + options as linkifyMatrixOptions, +} from './linkify-matrix'; import { IExtendedSanitizeOptions } from './@types/sanitize-html'; import SettingsStore from './settings/SettingsStore'; import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks"; import { getEmojiFromUnicode } from "./emoji"; import { mediaFromMxc } from "./customisations/Media"; -import { ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from './linkify-matrix'; import { stripHTMLReply, stripPlainReply } from './utils/Reply'; // Anything outside the basic multilingual plane will be a surrogate pair @@ -45,10 +49,10 @@ const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/; const SYMBOL_PATTERN = /([\u2100-\u2bff])/; // Regex pattern for Zero-Width joiner unicode characters -const ZWJ_REGEX = new RegExp("\u200D|\u2003", "g"); +const ZWJ_REGEX = /[\u200D\u2003]/g; // Regex pattern for whitespace characters -const WHITESPACE_REGEX = new RegExp("\\s", "g"); +const WHITESPACE_REGEX = /\s/g; const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i'); @@ -240,6 +244,7 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to } return { tagName, attribs }; }, + // eslint-disable-next-line @typescript-eslint/naming-convention '*': function(tagName: string, attribs: sanitizeHtml.Attributes) { // Delete any style previously assigned, style is an allowedTag for font, span & img, // because attributes are stripped after transforming. diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts index d4f4ffc6811..8ce30252f92 100644 --- a/src/KeyBindingsDefaults.ts +++ b/src/KeyBindingsDefaults.ts @@ -15,14 +15,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { isMac, Key } from "./Keyboard"; +import { IS_MAC, Key } from "./Keyboard"; import SettingsStore from "./settings/SettingsStore"; import SdkConfig from "./SdkConfig"; -import { - IKeyBindingsProvider, - KeyBinding, - KeyCombo, -} from "./KeyBindingsManager"; +import { IKeyBindingsProvider, KeyBinding } from "./KeyBindingsManager"; import { CATEGORIES, CategoryName, @@ -31,13 +27,10 @@ import { import { getKeyboardShortcuts } from "./accessibility/KeyboardShortcutUtils"; export const getBindingsByCategory = (category: CategoryName): KeyBinding[] => { - return CATEGORIES[category].settingNames.reduce((bindings, name) => { - const value = getKeyboardShortcuts()[name]?.default; - if (value) { - bindings.push({ - action: name as KeyBindingAction, - keyCombo: value as KeyCombo, - }); + return CATEGORIES[category].settingNames.reduce((bindings, action) => { + const keyCombo = getKeyboardShortcuts()[action]?.default; + if (keyCombo) { + bindings.push({ action, keyCombo }); } return bindings; }, []); @@ -81,7 +74,7 @@ const messageComposerBindings = (): KeyBinding[] => { shiftKey: true, }, }); - if (isMac) { + if (IS_MAC) { bindings.push({ action: KeyBindingAction.NewLine, keyCombo: { diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index 7a79a69ce87..aee403e31d1 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -17,7 +17,7 @@ limitations under the License. import { KeyBindingAction } from "./accessibility/KeyboardShortcuts"; import { defaultBindingsProvider } from './KeyBindingsDefaults'; -import { isMac } from './Keyboard'; +import { IS_MAC } from './Keyboard'; /** * Represent a key combination. @@ -127,7 +127,7 @@ export class KeyBindingsManager { ): KeyBindingAction | undefined { for (const getter of getters) { const bindings = getter(); - const binding = bindings.find(it => isKeyComboMatch(ev, it.keyCombo, isMac)); + const binding = bindings.find(it => isKeyComboMatch(ev, it.keyCombo, IS_MAC)); if (binding) { return binding.action; } diff --git a/src/Keyboard.ts b/src/Keyboard.ts index 8d7d39fc190..efecd791fd8 100644 --- a/src/Keyboard.ts +++ b/src/Keyboard.ts @@ -74,10 +74,10 @@ export const Key = { Z: "z", }; -export const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; +export const IS_MAC = navigator.platform.toUpperCase().includes('MAC'); export function isOnlyCtrlOrCmdKeyEvent(ev) { - if (isMac) { + if (IS_MAC) { return ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey; } else { return ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey; @@ -85,7 +85,7 @@ export function isOnlyCtrlOrCmdKeyEvent(ev) { } export function isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) { - if (isMac) { + if (IS_MAC) { return ev.metaKey && !ev.altKey && !ev.ctrlKey; } else { return ev.ctrlKey && !ev.altKey && !ev.metaKey; diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index da4010c57ea..516e18ddc73 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -36,7 +36,6 @@ import dis from './dispatcher/dispatcher'; import DMRoomMap from './utils/DMRoomMap'; import Modal from './Modal'; import ActiveWidgetStore from './stores/ActiveWidgetStore'; -import VideoChannelStore from "./stores/VideoChannelStore"; import PlatformPeg from "./PlatformPeg"; import { sendLoginRequest } from "./Login"; import * as StorageManager from './utils/StorageManager'; @@ -62,6 +61,7 @@ import { setSentryUser } from "./sentry"; import SdkConfig from "./SdkConfig"; import { DialogOpener } from "./utils/DialogOpener"; import { Action } from "./dispatcher/actions"; +import AbstractLocalStorageSettingsHandler from "./settings/handlers/AbstractLocalStorageSettingsHandler"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -807,7 +807,6 @@ async function startMatrixClient(startSyncing = true): Promise { IntegrationManagers.sharedInstance().startWatching(); ActiveWidgetStore.instance.start(); CallHandler.instance.start(); - if (SettingsStore.getValue("feature_video_rooms")) VideoChannelStore.instance.start(); // Start Mjolnir even though we haven't checked the feature flag yet. Starting // the thing just wastes CPU cycles, but should result in no actual functionality @@ -834,7 +833,7 @@ async function startMatrixClient(startSyncing = true): Promise { } // Now that we have a MatrixClientPeg, update the Jitsi info - await Jitsi.getInstance().start(); + Jitsi.getInstance().start(); // dispatch that we finished starting up to wire up any other bits // of the matrix client that cannot be set prior to starting up. @@ -880,6 +879,7 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise { const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); - MatrixClientPeg.get().getMediaHandler().setAudioInput(audioDeviceId); - MatrixClientPeg.get().getMediaHandler().setVideoInput(videoDeviceId); + await MatrixClientPeg.get().getMediaHandler().setAudioInput(audioDeviceId); + await MatrixClientPeg.get().getMediaHandler().setVideoInput(videoDeviceId); } public setAudioOutput(deviceId: string): void { @@ -90,9 +90,9 @@ export default class MediaDeviceHandler extends EventEmitter { * need to be ended and started again for this change to take effect * @param {string} deviceId */ - public setAudioInput(deviceId: string): void { + public async setAudioInput(deviceId: string): Promise { SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId); - MatrixClientPeg.get().getMediaHandler().setAudioInput(deviceId); + return MatrixClientPeg.get().getMediaHandler().setAudioInput(deviceId); } /** @@ -100,16 +100,16 @@ export default class MediaDeviceHandler extends EventEmitter { * need to be ended and started again for this change to take effect * @param {string} deviceId */ - public setVideoInput(deviceId: string): void { + public async setVideoInput(deviceId: string): Promise { SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId); - MatrixClientPeg.get().getMediaHandler().setVideoInput(deviceId); + return MatrixClientPeg.get().getMediaHandler().setVideoInput(deviceId); } - public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void { + public async setDevice(deviceId: string, kind: MediaDeviceKindEnum): Promise { switch (kind) { case MediaDeviceKindEnum.AudioOutput: this.setAudioOutput(deviceId); break; - case MediaDeviceKindEnum.AudioInput: this.setAudioInput(deviceId); break; - case MediaDeviceKindEnum.VideoInput: this.setVideoInput(deviceId); break; + case MediaDeviceKindEnum.AudioInput: await this.setAudioInput(deviceId); break; + case MediaDeviceKindEnum.VideoInput: await this.setVideoInput(deviceId); break; } } @@ -124,4 +124,17 @@ export default class MediaDeviceHandler extends EventEmitter { public static getVideoInput(): string { return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_videoinput"); } + + /** + * Returns the current set deviceId for a device kind + * @param {MediaDeviceKindEnum} kind of the device that will be returned + * @returns {string} the deviceId + */ + public static getDevice(kind: MediaDeviceKindEnum): string { + switch (kind) { + case MediaDeviceKindEnum.AudioOutput: return this.getAudioOutput(); + case MediaDeviceKindEnum.AudioInput: return this.getAudioInput(); + case MediaDeviceKindEnum.VideoInput: return this.getVideoInput(); + } + } } diff --git a/src/NodeAnimator.tsx b/src/NodeAnimator.tsx index 1a8942f5f53..2bb79542404 100644 --- a/src/NodeAnimator.tsx +++ b/src/NodeAnimator.tsx @@ -1,3 +1,19 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + import React from "react"; import ReactDom from "react-dom"; diff --git a/src/Notifier.ts b/src/Notifier.ts index 62e2f093703..892d5bc19cc 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -408,10 +408,6 @@ export const Notifier = { // don't bother notifying as user was recently active in this room return; } - if (SettingsStore.getValue("doNotDisturb")) { - // Don't bother the user if they didn't ask to be bothered - return; - } if (this.isEnabled()) { this._displayPopupNotification(ev, room); diff --git a/src/PasswordReset.ts b/src/PasswordReset.ts index f5d9ab77dcb..df812bafb2c 100644 --- a/src/PasswordReset.ts +++ b/src/PasswordReset.ts @@ -31,6 +31,7 @@ export default class PasswordReset { private clientSecret: string; private password: string; private sessionId: string; + private logoutDevices: boolean; /** * Configure the endpoints for password resetting. @@ -50,10 +51,16 @@ export default class PasswordReset { * sending an email to the provided email address. * @param {string} emailAddress The email address * @param {string} newPassword The new password for the account. + * @param {boolean} logoutDevices Should all devices be signed out after the reset? Defaults to `true`. * @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked(). */ - public resetPassword(emailAddress: string, newPassword: string): Promise { + public resetPassword( + emailAddress: string, + newPassword: string, + logoutDevices = true, + ): Promise { this.password = newPassword; + this.logoutDevices = logoutDevices; return this.client.requestPasswordEmailToken(emailAddress, this.clientSecret, 1).then((res) => { this.sessionId = res.sid; return res; @@ -90,7 +97,7 @@ export default class PasswordReset { // See https://github.com/matrix-org/matrix-doc/issues/2220 threepid_creds: creds, threepidCreds: creds, - }, this.password); + }, this.password, this.logoutDevices); } catch (err) { if (err.httpStatus === 401) { err.message = _t('Failed to verify email address: make sure you clicked the link in the email'); diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 01a8f52d8ac..3ce8707d1c2 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -18,6 +18,7 @@ import posthog, { PostHog } from 'posthog-js'; import { MatrixClient } from "matrix-js-sdk/src/client"; import { logger } from "matrix-js-sdk/src/logger"; import { UserProperties } from "matrix-analytics-events/types/typescript/UserProperties"; +import { Signup } from 'matrix-analytics-events/types/typescript/Signup'; import PlatformPeg from './PlatformPeg'; import SdkConfig from './SdkConfig'; @@ -50,6 +51,10 @@ export interface IPosthogEvent { "$set_once"?: void; } +export interface IPostHogEventOptions { + timestamp?: Date; +} + export enum Anonymity { Disabled, Anonymous, @@ -117,6 +122,7 @@ export class PosthogAnalytics { private static ANALYTICS_EVENT_TYPE = "im.vector.analytics"; private propertiesForNextEvent: Partial> = {}; private userPropertyCache: UserProperties = {}; + private authenticationType: Signup["authenticationType"] = "Other"; public static get instance(): PosthogAnalytics { if (!this._instance) { @@ -200,16 +206,20 @@ export class PosthogAnalytics { }; } - private capture(eventName: string, properties: posthog.Properties) { + // eslint-disable-nextline no-unused-varsx + private capture(eventName: string, properties: posthog.Properties, options?: IPostHogEventOptions) { if (!this.enabled) { return; } const { origin, hash, pathname } = window.location; properties["redactedCurrentUrl"] = getRedactedCurrentLocation(origin, hash, pathname); - this.posthog.capture(eventName, { - ...this.propertiesForNextEvent, - ...properties, - }); + this.posthog.capture( + eventName, + { ...this.propertiesForNextEvent, ...properties }, + // TODO: Uncomment below once https://github.com/PostHog/posthog-js/pull/391 + // gets merged + /* options as any, */ // No proper type definition in the posthog library + ); this.propertiesForNextEvent = {}; } @@ -272,9 +282,12 @@ export class PosthogAnalytics { this.setAnonymity(Anonymity.Disabled); } - public trackEvent({ eventName, ...properties }: E): void { + public trackEvent( + { eventName, ...properties }: E, + options?: IPostHogEventOptions, + ): void { if (this.anonymity == Anonymity.Disabled || this.anonymity == Anonymity.Anonymous) return; - this.capture(eventName, properties); + this.capture(eventName, properties, options); } public setProperty(key: K, value: UserProperties[K]): void { @@ -313,6 +326,9 @@ export class PosthogAnalytics { this.setAnonymity(anonymity); if (anonymity === Anonymity.Pseudonymous) { await this.identifyUser(MatrixClientPeg.get(), PosthogAnalytics.getRandomAnalyticsId); + if (MatrixClientPeg.currentUserIsJustRegistered()) { + this.trackNewUserEvent(); + } } if (anonymity !== Anonymity.Disabled) { @@ -334,4 +350,25 @@ export class PosthogAnalytics { this.updateAnonymityFromSettings(!!newValue); }); } + + public setAuthenticationType(authenticationType: Signup["authenticationType"]): void { + this.authenticationType = authenticationType; + } + + private trackNewUserEvent(): void { + // This is the only event that could have occured before analytics opt-in + // that we want to accumulate before the user has given consent + // All other scenarios should not track a user before they have given + // explicit consent that they are ok with their analytics data being collected + const options: IPostHogEventOptions = {}; + const registrationTime = parseInt(window.localStorage.getItem("mx_registration_time"), 10); + if (!isNaN(registrationTime)) { + options.timestamp = new Date(registrationTime); + } + + return this.trackEvent({ + eventName: "Signup", + authenticationType: this.authenticationType, + }, options); + } } diff --git a/src/RoomInvite.tsx b/src/RoomInvite.tsx index 200da2f7cf8..e9204996ed2 100644 --- a/src/RoomInvite.tsx +++ b/src/RoomInvite.tsx @@ -19,6 +19,7 @@ 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 { logger } from "matrix-js-sdk/src/logger"; +import { EventType } from "matrix-js-sdk/src/@types/event"; import { MatrixClientPeg } from './MatrixClientPeg'; import MultiInviter, { CompletionStates } from './utils/MultiInviter'; @@ -84,12 +85,12 @@ export function showRoomInviteDialog(roomId: string, initialText = ""): void { * @returns {boolean} True if valid, false otherwise */ export function isValid3pidInvite(event: MatrixEvent): boolean { - if (!event || event.getType() !== "m.room.third_party_invite") return false; + if (!event || event.getType() !== EventType.RoomThirdPartyInvite) return false; // any events without these keys are not valid 3pid invites, so we ignore them const requiredKeys = ['key_validity_url', 'public_key', 'display_name']; - for (let i = 0; i < requiredKeys.length; ++i) { - if (!event.getContent()[requiredKeys[i]]) return false; + if (requiredKeys.some(key => !event.getContent()[key])) { + return false; } // Valid enough by our standards diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index f3d254d0590..c67e8ec8d96 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -83,9 +83,11 @@ async function confirmToDismiss(): Promise { return !sure; } +type KeyParams = { passphrase: string, recoveryKey: string }; + function makeInputToKey( keyInfo: ISecretStorageKeyInfo, -): (keyParams: { passphrase: string, recoveryKey: string }) => Promise { +): (keyParams: KeyParams) => Promise { return async ({ passphrase, recoveryKey }) => { if (passphrase) { return deriveKey( @@ -101,11 +103,10 @@ function makeInputToKey( async function getSecretStorageKey( { keys: keyInfos }: { keys: Record }, - ssssItemName, ): Promise<[string, Uint8Array]> { const cli = MatrixClientPeg.get(); let keyId = await cli.getDefaultSecretStorageKeyId(); - let keyInfo; + let keyInfo: ISecretStorageKeyInfo; if (keyId) { // use the default SSSS key if set keyInfo = keyInfos[keyId]; @@ -154,9 +155,9 @@ async function getSecretStorageKey( /* props= */ { keyInfo, - checkPrivateKey: async (input) => { + checkPrivateKey: async (input: KeyParams) => { const key = await inputToKey(input); - return await MatrixClientPeg.get().checkSecretStorageKey(key, keyInfo); + return MatrixClientPeg.get().checkSecretStorageKey(key, keyInfo); }, }, /* className= */ null, @@ -171,11 +172,11 @@ async function getSecretStorageKey( }, }, ); - const [input] = await finished; - if (!input) { + const [keyParams] = await finished; + if (!keyParams) { throw new AccessCancelledError(); } - const key = await inputToKey(input); + const key = await inputToKey(keyParams); // Save to cache to avoid future prompts in the current session cacheSecretStorageKey(keyId, keyInfo, key); diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index bb6d9eab3c7..04d1726f3d5 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -224,7 +224,7 @@ const onViewJoinRuleSettingsClick = () => { }); }; -function textForJoinRulesEvent(ev: MatrixEvent, allowJSX: boolean): () => string | JSX.Element | null { +function textForJoinRulesEvent(ev: MatrixEvent, allowJSX: boolean): () => Renderable { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); switch (ev.getContent().join_rule) { case JoinRule.Public: @@ -281,7 +281,7 @@ function textForServerACLEvent(ev: MatrixEvent): () => string | null { const prev = { deny: Array.isArray(prevContent.deny) ? prevContent.deny : [], allow: Array.isArray(prevContent.allow) ? prevContent.allow : [], - allow_ip_literals: !(prevContent.allow_ip_literals === false), + allow_ip_literals: prevContent.allow_ip_literals !== false, }; let getText = null; @@ -372,13 +372,15 @@ function textForCanonicalAliasEvent(ev: MatrixEvent): () => string | null { addresses: addedAltAliases.join(", "), count: addedAltAliases.length, }); - } if (removedAltAliases.length && !addedAltAliases.length) { + } + if (removedAltAliases.length && !addedAltAliases.length) { return () => _t('%(senderName)s removed the alternative addresses %(addresses)s for this room.', { senderName, addresses: removedAltAliases.join(", "), count: removedAltAliases.length, }); - } if (removedAltAliases.length && addedAltAliases.length) { + } + if (removedAltAliases.length && addedAltAliases.length) { return () => _t('%(senderName)s changed the alternative addresses for this room.', { senderName, }); @@ -504,7 +506,7 @@ const onPinnedMessagesClick = (): void => { RightPanelStore.instance.setCard({ phase: RightPanelPhases.PinnedMessages }, false); }; -function textForPinnedEvent(event: MatrixEvent, allowJSX: boolean): () => string | JSX.Element | null { +function textForPinnedEvent(event: MatrixEvent, allowJSX: boolean): () => Renderable { if (!SettingsStore.getValue("feature_pinning")) return null; const senderName = getSenderName(event); const roomId = event.getRoomId(); @@ -758,10 +760,12 @@ function textForPollEndEvent(event: MatrixEvent): () => string | null { }); } +type Renderable = string | JSX.Element | null; + interface IHandlers { [type: string]: (ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean) => - (() => string | JSX.Element | null); + (() => Renderable); } const handlers: IHandlers = { diff --git a/src/accessibility/KeyboardShortcutUtils.ts b/src/accessibility/KeyboardShortcutUtils.ts index 434116d4303..1dff38cde34 100644 --- a/src/accessibility/KeyboardShortcutUtils.ts +++ b/src/accessibility/KeyboardShortcutUtils.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { KeyCombo } from "../KeyBindingsManager"; -import { isMac, Key } from "../Keyboard"; +import { IS_MAC, Key } from "../Keyboard"; import { _t, _td } from "../languageHandler"; import PlatformPeg from "../PlatformPeg"; import SettingsStore from "../settings/SettingsStore"; @@ -96,7 +96,7 @@ export const getKeyboardShortcuts = (): IKeyboardShortcuts => { return Object.keys(KEYBOARD_SHORTCUTS).filter((k: KeyBindingAction) => { if (KEYBOARD_SHORTCUTS[k]?.controller?.settingDisabled) return false; - if (MAC_ONLY_SHORTCUTS.includes(k) && !isMac) return false; + if (MAC_ONLY_SHORTCUTS.includes(k) && !IS_MAC) return false; if (DESKTOP_SHORTCUTS.includes(k) && !overrideBrowserShortcuts) return false; return true; diff --git a/src/accessibility/KeyboardShortcuts.ts b/src/accessibility/KeyboardShortcuts.ts index 97e428d2a0f..50992eb299a 100644 --- a/src/accessibility/KeyboardShortcuts.ts +++ b/src/accessibility/KeyboardShortcuts.ts @@ -16,7 +16,7 @@ limitations under the License. */ import { _td } from "../languageHandler"; -import { isMac, Key } from "../Keyboard"; +import { IS_MAC, Key } from "../Keyboard"; import { IBaseSetting } from "../settings/Settings"; import IncompatibleController from "../settings/controllers/IncompatibleController"; import { KeyCombo } from "../KeyBindingsManager"; @@ -200,7 +200,7 @@ export const KEY_ICON: Record = { [Key.ARROW_LEFT]: "←", [Key.ARROW_RIGHT]: "→", }; -if (isMac) { +if (IS_MAC) { KEY_ICON[Key.META] = "⌘"; KEY_ICON[Key.ALT] = "⌥"; } @@ -528,8 +528,8 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = { [KeyBindingAction.GoToHome]: { default: { ctrlOrCmdKey: true, - altKey: !isMac, - shiftKey: isMac, + altKey: !IS_MAC, + shiftKey: IS_MAC, key: Key.H, }, displayName: _td("Go to Home View"), @@ -621,25 +621,25 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = { }, [KeyBindingAction.EditRedo]: { default: { - key: isMac ? Key.Z : Key.Y, + key: IS_MAC ? Key.Z : Key.Y, ctrlOrCmdKey: true, - shiftKey: isMac, + shiftKey: IS_MAC, }, displayName: _td("Redo edit"), }, [KeyBindingAction.PreviousVisitedRoomOrSpace]: { default: { - metaKey: isMac, - altKey: !isMac, - key: isMac ? Key.SQUARE_BRACKET_LEFT : Key.ARROW_LEFT, + metaKey: IS_MAC, + altKey: !IS_MAC, + key: IS_MAC ? Key.SQUARE_BRACKET_LEFT : Key.ARROW_LEFT, }, displayName: _td("Previous recently visited room or space"), }, [KeyBindingAction.NextVisitedRoomOrSpace]: { default: { - metaKey: isMac, - altKey: !isMac, - key: isMac ? Key.SQUARE_BRACKET_RIGHT : Key.ARROW_RIGHT, + metaKey: IS_MAC, + altKey: !IS_MAC, + key: IS_MAC ? Key.SQUARE_BRACKET_RIGHT : Key.ARROW_RIGHT, }, displayName: _td("Next recently visited room or space"), }, diff --git a/src/accessibility/roving/RovingAccessibleButton.tsx b/src/accessibility/roving/RovingAccessibleButton.tsx index f9ce87db6a7..0d9025dd59f 100644 --- a/src/accessibility/roving/RovingAccessibleButton.tsx +++ b/src/accessibility/roving/RovingAccessibleButton.tsx @@ -20,13 +20,21 @@ import AccessibleButton from "../../components/views/elements/AccessibleButton"; import { useRovingTabIndex } from "../RovingTabIndex"; import { Ref } from "./types"; -interface IProps extends Omit, "onFocus" | "inputRef" | "tabIndex"> { +interface IProps extends Omit, "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 }) => { - const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); - return ; +export const RovingAccessibleButton: React.FC = ({ inputRef, onFocus, ...props }) => { + const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef); + return { + onFocusInternal(); + onFocus?.(event); + }} + inputRef={ref} + tabIndex={isActive ? 0 : -1} + />; }; diff --git a/src/accessibility/roving/RovingAccessibleTooltipButton.tsx b/src/accessibility/roving/RovingAccessibleTooltipButton.tsx index d9e393d7282..432e8880173 100644 --- a/src/accessibility/roving/RovingAccessibleTooltipButton.tsx +++ b/src/accessibility/roving/RovingAccessibleTooltipButton.tsx @@ -21,13 +21,21 @@ import { useRovingTabIndex } from "../RovingTabIndex"; import { Ref } from "./types"; type ATBProps = React.ComponentProps; -interface IProps extends Omit { +interface IProps extends Omit { inputRef?: Ref; } // Wrapper to allow use of useRovingTabIndex for simple AccessibleTooltipButtons outside of React Functional Components. -export const RovingAccessibleTooltipButton: React.FC = ({ inputRef, ...props }) => { - const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); - return ; +export const RovingAccessibleTooltipButton: React.FC = ({ inputRef, onFocus, ...props }) => { + const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef); + return { + onFocusInternal(); + onFocus?.(event); + }} + inputRef={ref} + tabIndex={isActive ? 0 : -1} + />; }; diff --git a/src/actions/MatrixActionCreators.ts b/src/actions/MatrixActionCreators.ts index c4d75cc854a..b8893763361 100644 --- a/src/actions/MatrixActionCreators.ts +++ b/src/actions/MatrixActionCreators.ts @@ -282,7 +282,8 @@ function addMatrixClientListener( const listener: Listener = (...args) => { const payload = actionCreator(matrixClient, ...args); if (payload) { - dis.dispatch(payload, true); + // Consumers shouldn't have to worry about calling js-sdk methods mid-dispatch, so make this dispatch async + dis.dispatch(payload, false); } }; matrixClient.on(eventName, listener); diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx index 53df137f6d6..190e683cf2b 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx @@ -276,7 +276,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent void): Promise => { if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) { - await makeRequest({ + makeRequest({ type: 'm.login.password', identifier: { type: 'm.id.user', diff --git a/src/autocomplete/Autocompleter.ts b/src/autocomplete/Autocompleter.ts index 880f4e68732..0c7ef1afb2e 100644 --- a/src/autocomplete/Autocompleter.ts +++ b/src/autocomplete/Autocompleter.ts @@ -95,7 +95,7 @@ export default class Autocompleter { */ // list of results from each provider, each being a list of completions or null if it times out const completionsList: ICompletion[][] = await Promise.all(this.providers.map(async provider => { - return await timeout( + return timeout( provider.getCompletions(query, selection, force, limit), null, PROVIDER_COMPLETION_TIMEOUT, diff --git a/src/boundThreepids.ts b/src/boundThreepids.ts index a703d10fd78..6421c1309aa 100644 --- a/src/boundThreepids.ts +++ b/src/boundThreepids.ts @@ -53,7 +53,7 @@ export async function getThreepidsWithBindStatus( } } catch (e) { // Ignore terms errors here and assume other flows handle this - if (!(e.errcode === "M_TERMS_NOT_SIGNED")) { + if (e.errcode !== "M_TERMS_NOT_SIGNED") { throw e; } } diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 2f233c02ed1..695d6ec2a7b 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -82,6 +82,7 @@ export interface IProps extends IPosition { // whether this context menu should be focus managed. If false it must handle itself managed?: boolean; wrapperClassName?: string; + menuClassName?: string; // If true, this context menu will be mounted as a child to the parent container. Otherwise // it will be mounted to a container at the root of the DOM. @@ -156,12 +157,14 @@ export default class ContextMenu extends React.PureComponent { // XXX: This isn't pretty but the only way to allow opening a different context menu on right click whilst // a context menu and its click-guard are up without completely rewriting how the context menus work. setImmediate(() => { - const clickEvent = document.createEvent('MouseEvents'); - clickEvent.initMouseEvent( - 'contextmenu', true, true, window, 0, - 0, 0, x, y, false, false, - false, false, 0, null, - ); + const clickEvent = new MouseEvent("contextmenu", { + clientX: x, + clientY: y, + screenX: 0, + screenY: 0, + button: 0, // Left + relatedTarget: null, + }); document.elementFromPoint(x, y).dispatchEvent(clickEvent); }); } @@ -319,7 +322,7 @@ export default class ContextMenu extends React.PureComponent { 'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom, 'mx_ContextualMenu_rightAligned': this.props.rightAligned === true, 'mx_ContextualMenu_bottomAligned': this.props.bottomAligned === true, - }); + }, this.props.menuClassName); const menuStyle: CSSProperties = {}; if (props.menuWidth) { @@ -416,8 +419,8 @@ export type ToRightOf = { // Placement method for to position context menu to right of elementRect with chevronOffset export const toRightOf = (elementRect: Pick, chevronOffset = 12): ToRightOf => { - const left = elementRect.right + window.pageXOffset + 3; - let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset; + const left = elementRect.right + window.scrollX + 3; + let top = elementRect.top + (elementRect.height / 2) + window.scrollY; top -= chevronOffset + 8; // where 8 is half the height of the chevron return { left, top, chevronOffset }; }; @@ -429,15 +432,15 @@ export type AboveLeftOf = IPosition & { // Placement method for to position context menu right-aligned and flowing to the left of elementRect, // and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?) export const aboveLeftOf = ( - elementRect: DOMRect, + elementRect: Pick, chevronFace = ChevronFace.None, vPadding = 0, ): AboveLeftOf => { const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; - const buttonRight = elementRect.right + window.pageXOffset; - const buttonBottom = elementRect.bottom + window.pageYOffset; - const buttonTop = elementRect.top + window.pageYOffset; + const buttonRight = elementRect.right + window.scrollX; + const buttonBottom = elementRect.bottom + window.scrollY; + const buttonTop = elementRect.top + window.scrollY; // Align the right edge of the menu to the right edge of the button menuOptions.right = UIStore.instance.windowWidth - buttonRight; // Align the menu vertically on whichever side of the button has more space available. @@ -450,14 +453,42 @@ export const aboveLeftOf = ( return menuOptions; }; +// Placement method for to position context menu right-aligned and flowing to the right of elementRect, +// and either above or below: wherever there is more space (maybe this should be aboveOrBelowRightOf?) +export const aboveRightOf = ( + elementRect: Pick, + chevronFace = ChevronFace.None, + vPadding = 0, +): AboveLeftOf => { + const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; + + const buttonLeft = elementRect.left + window.scrollX; + const buttonBottom = elementRect.bottom + window.scrollY; + const buttonTop = elementRect.top + window.scrollY; + // Align the left edge of the menu to the left edge of the button + menuOptions.left = buttonLeft; + // Align the menu vertically on whichever side of the button has more space available. + if (buttonBottom < UIStore.instance.windowHeight / 2) { + menuOptions.top = buttonBottom + vPadding; + } else { + menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding; + } + + return menuOptions; +}; + // Placement method for to position context menu right-aligned and flowing to the left of elementRect // and always above elementRect -export const alwaysAboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => { +export const alwaysAboveLeftOf = ( + elementRect: Pick, + chevronFace = ChevronFace.None, + vPadding = 0, +) => { const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; - const buttonRight = elementRect.right + window.pageXOffset; - const buttonBottom = elementRect.bottom + window.pageYOffset; - const buttonTop = elementRect.top + window.pageYOffset; + const buttonRight = elementRect.right + window.scrollX; + const buttonBottom = elementRect.bottom + window.scrollY; + const buttonTop = elementRect.top + window.scrollY; // Align the right edge of the menu to the right edge of the button menuOptions.right = UIStore.instance.windowWidth - buttonRight; // Align the menu vertically on whichever side of the button has more space available. @@ -472,11 +503,15 @@ export const alwaysAboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFac // Placement method for to position context menu right-aligned and flowing to the right of elementRect // and always above elementRect -export const alwaysAboveRightOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => { +export const alwaysAboveRightOf = ( + elementRect: Pick, + chevronFace = ChevronFace.None, + vPadding = 0, +) => { const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; - const buttonLeft = elementRect.left + window.pageXOffset; - const buttonTop = elementRect.top + window.pageYOffset; + const buttonLeft = elementRect.left + window.scrollX; + const buttonTop = elementRect.top + window.scrollY; // Align the left edge of the menu to the left edge of the button menuOptions.left = buttonLeft; // Align the menu vertically above the menu diff --git a/src/components/structures/HomePage.tsx b/src/components/structures/HomePage.tsx index fdcb52a6878..ddba1c81d71 100644 --- a/src/components/structures/HomePage.tsx +++ b/src/components/structures/HomePage.tsx @@ -34,13 +34,15 @@ import Analytics from "../../Analytics"; import PosthogTrackers from "../../PosthogTrackers"; import EmbeddedPage from "./EmbeddedPage"; -const onClickSendDm = () => { +const onClickSendDm = (ev: ButtonEvent) => { Analytics.trackEvent('home_page', 'button', 'dm'); + PosthogTrackers.trackInteraction("WebHomeCreateChatButton", ev); dis.dispatch({ action: 'view_create_chat' }); }; -const onClickExplore = () => { +const onClickExplore = (ev: ButtonEvent) => { Analytics.trackEvent('home_page', 'button', 'room_directory'); + PosthogTrackers.trackInteraction("WebHomeExploreRoomsButton", ev); dis.fire(Action.ViewRoomDirectory); }; diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 9b4e98e66cb..ed1f990c3f8 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -43,6 +43,8 @@ import SettingsStore from "../../settings/SettingsStore"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import { shouldShowComponent } from "../../customisations/helpers/UIComponents"; import { UIComponent } from "../../settings/UIFeature"; +import { ButtonEvent } from "../views/elements/AccessibleButton"; +import PosthogTrackers from "../../PosthogTrackers"; interface IProps { isMinimized: boolean; @@ -116,8 +118,9 @@ export default class LeftPanel extends React.Component { dis.fire(Action.OpenDialPad); }; - private onExplore = () => { + private onExplore = (ev: ButtonEvent) => { dis.fire(Action.ViewRoomDirectory); + PosthogTrackers.trackInteraction("WebLeftPanelExploreRoomsButton", ev); }; private refreshStickyHeaders = () => { diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 624909db31d..b7e89b08a40 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -129,8 +129,8 @@ import { DoAfterSyncPreparedPayload } from '../../dispatcher/payloads/DoAfterSyn import { ViewStartChatOrReusePayload } from '../../dispatcher/payloads/ViewStartChatOrReusePayload'; import { IConfigOptions } from "../../IConfigOptions"; import { SnakedObject } from "../../utils/SnakedObject"; -import InfoDialog from '../views/dialogs/InfoDialog'; import { leaveRoomBehaviour } from "../../utils/leave-behaviour"; +import VideoChannelStore from "../../stores/VideoChannelStore"; // legacy export export { default as Views } from "../../Views"; @@ -576,6 +576,7 @@ export default class MatrixChat extends React.PureComponent { break; case 'logout': CallHandler.instance.hangupAllCalls(); + if (VideoChannelStore.instance.connected) VideoChannelStore.instance.setDisconnected(); Lifecycle.logout(); break; case 'require_registration': @@ -1410,36 +1411,6 @@ export default class MatrixChat extends React.PureComponent { showNotificationsToast(false); } - if (!localStorage.getItem("mx_seen_feature_thread_experimental")) { - setTimeout(() => { - if (SettingsStore.getValue("feature_thread") && SdkConfig.get("show_labs_settings")) { - Modal.createDialog(InfoDialog, { - title: _t("Threads Approaching Beta 🎉"), - description: <> -

- { _t("We're getting closer to releasing a public Beta for Threads.") } -

-

- { _t("As we prepare for it, we need to make some changes: threads created " - + "before this point will be displayed as regular replies.", - {}, { - "strong": sub => { sub }, - }) } -

-

- { _t("This will be a one-off transition, as threads are now part " - + "of the Matrix specification.") } -

- , - button: _t("Got it"), - onFinished: () => { - localStorage.setItem("mx_seen_feature_thread_experimental", "true"); - }, - }); - } - }, 1 * 60 * 1000); // show after 1 minute to not overload user on launch - } - if (!localStorage.getItem("mx_seen_feature_spotlight_toast")) { setTimeout(() => { // Skip the toast if the beta is already enabled or the user has changed the setting from default diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index ff9bc826528..1a403ba5065 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -23,6 +23,8 @@ import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { Relations } from "matrix-js-sdk/src/models/relations"; import { logger } from 'matrix-js-sdk/src/logger'; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; +import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; +import { M_BEACON_INFO } from 'matrix-js-sdk/src/@types/beacon'; import shouldHideEvent from '../../shouldHideEvent'; import { wantsDateSeparator } from '../../DateUtils'; @@ -92,9 +94,13 @@ export function shouldFormContinuation( mxEvent.sender.name !== prevEvent.sender.name || mxEvent.sender.getMxcAvatarUrl() !== prevEvent.sender.getMxcAvatarUrl()) return false; - // Thread summaries in the main timeline should break up a continuation - if (threadsEnabled && prevEvent.isThreadRoot && - timelineRenderingType !== TimelineRenderingType.Thread) return false; + // Thread summaries in the main timeline should break up a continuation on both sides + if (threadsEnabled && + (mxEvent.isThreadRoot || prevEvent.isThreadRoot) && + timelineRenderingType !== TimelineRenderingType.Thread + ) { + return false; + } // if we don't have tile for previous event then it was shown by showHiddenEvents and has no SenderProfile if (!haveRendererForEvent(prevEvent, showHiddenEvents)) return false; @@ -452,14 +458,6 @@ export default class MessagePanel extends React.Component { } } - /* check the scroll state and send out pagination requests if necessary. - */ - public checkFillState(): void { - if (this.scrollPanel.current) { - this.scrollPanel.current.checkFillState(); - } - } - private isUnmounting = (): boolean => { return !this.isMounted; }; @@ -850,7 +848,7 @@ export default class MessagePanel extends React.Component { } const receipts: IReadReceiptProps[] = []; room.getReceiptsForEvent(event).forEach((r) => { - if (!r.userId || r.type !== "m.read" || r.userId === myUserId) { + if (!r.userId || ![ReceiptType.Read, ReceiptType.ReadPrivate].includes(r.type) || r.userId === myUserId) { return; // ignore non-read receipts and receipts from self. } if (MatrixClientPeg.get().isUserIgnored(r.userId)) { @@ -1083,7 +1081,7 @@ abstract class BaseGrouper { // Wrap initial room creation events into a GenericEventListSummary // Grouping only events sent by the same user that sent the `m.room.create` and only until -// the first non-state event or membership event which is not regarding the sender of the `m.room.create` event +// the first non-state event, beacon_info event or membership event which is not regarding the sender of the `m.room.create` event class CreationGrouper extends BaseGrouper { static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean { return ev.getType() === EventType.RoomCreate; @@ -1102,9 +1100,15 @@ class CreationGrouper extends BaseGrouper { && (ev.getStateKey() !== createEvent.getSender() || ev.getContent()["membership"] !== "join")) { return false; } + // beacons are not part of room creation configuration + // should be shown in timeline + if (M_BEACON_INFO.matches(ev.getType())) { + return false; + } if (ev.isState() && ev.getSender() === createEvent.getSender()) { return true; } + return false; } diff --git a/src/components/structures/RoomDirectory.tsx b/src/components/structures/RoomDirectory.tsx index 5577bc29e70..aa8f38556a7 100644 --- a/src/components/structures/RoomDirectory.tsx +++ b/src/components/structures/RoomDirectory.tsx @@ -16,7 +16,7 @@ limitations under the License. */ import React from "react"; -import { IFieldType, IInstance, IProtocol, IPublicRoomsChunkRoom } from "matrix-js-sdk/src/client"; +import { IFieldType, IPublicRoomsChunkRoom } from "matrix-js-sdk/src/client"; import { Visibility } from "matrix-js-sdk/src/@types/partials"; import { IRoomDirectoryOptions } from "matrix-js-sdk/src/@types/requests"; import { logger } from "matrix-js-sdk/src/logger"; @@ -26,9 +26,9 @@ import dis from "../../dispatcher/dispatcher"; import Modal from "../../Modal"; import { _t } from '../../languageHandler'; import SdkConfig from '../../SdkConfig'; -import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; +import { instanceForInstanceId, protocolNameForInstanceId, ALL_ROOMS, Protocols } from '../../utils/DirectoryUtils'; import Analytics from '../../Analytics'; -import NetworkDropdown, { ALL_ROOMS, Protocols } from "../views/directory/NetworkDropdown"; +import NetworkDropdown from "../views/directory/NetworkDropdown"; import SettingsStore from "../../settings/SettingsStore"; import { IDialogProps } from "../views/dialogs/IDialogProps"; import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; @@ -39,10 +39,10 @@ import DirectorySearchBox from "../views/elements/DirectorySearchBox"; import ScrollPanel from "./ScrollPanel"; import Spinner from "../views/elements/Spinner"; import { getDisplayAliasForAliasSet } from "../../Rooms"; -import { Action } from "../../dispatcher/actions"; import PosthogTrackers from "../../PosthogTrackers"; -import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { PublicRoomTile } from "../views/rooms/PublicRoomTile"; +import { getFieldsForThirdPartyLocation, joinRoomByAlias, showRoom } from "../../utils/rooms"; +import { GenericError } from "../../utils/error"; const LAST_SERVER_KEY = "mx_last_room_directory_server"; const LAST_INSTANCE_KEY = "mx_last_room_directory_instance"; @@ -350,44 +350,23 @@ export default class RoomDirectory extends React.Component { }; private onJoinFromSearchClick = (alias: string) => { - // If we don't have a particular instance id selected, just show that rooms alias - if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) { - // If the user specified an alias without a domain, add on whichever server is selected - // in the dropdown - if (alias.indexOf(':') == -1) { - alias = alias + ':' + this.state.roomServer; - } - this.showRoomAlias(alias, true); - } else { - // This is a 3rd party protocol. Let's see if we can join it - const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId); - const instance = instanceForInstanceId(this.protocols, this.state.instanceId); - const fields = protocolName - ? this.getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance) - : null; - if (!fields) { - const brand = SdkConfig.get().brand; - Modal.createTrackedDialog('Unable to join network', '', ErrorDialog, { - title: _t('Unable to join network'), - description: _t('%(brand)s does not know how to join a room on this network', { brand }), + const cli = MatrixClientPeg.get(); + try { + joinRoomByAlias(cli, alias, { + instanceId: this.state.instanceId, + roomServer: this.state.roomServer, + protocols: this.protocols, + metricsTrigger: "RoomDirectory", + }); + } catch (e) { + if (e instanceof GenericError) { + Modal.createTrackedDialog(e.message, '', ErrorDialog, { + title: e.message, + description: e.description, }); - return; + } else { + throw e; } - MatrixClientPeg.get().getThirdpartyLocation(protocolName, fields).then((resp) => { - if (resp.length > 0 && resp[0].alias) { - this.showRoomAlias(resp[0].alias, true); - } else { - Modal.createTrackedDialog('Room not found', '', ErrorDialog, { - title: _t('Room not found'), - description: _t('Couldn\'t find a matching Matrix room'), - }); - } - }, (e) => { - Modal.createTrackedDialog('Fetching third party location failed', '', ErrorDialog, { - title: _t('Fetching third party location failed'), - description: _t('Unable to look up room ID from server'), - }); - }); } }; @@ -401,55 +380,18 @@ export default class RoomDirectory extends React.Component { PosthogTrackers.trackInteraction("WebRoomDirectoryCreateRoomButton", ev); }; - private showRoomAlias(alias: string, autoJoin = false) { - this.showRoom(null, alias, autoJoin); - } - - private showRoom = (room: IPublicRoomsChunkRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) => { + private onRoomClick = (room: IPublicRoomsChunkRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) => { this.onFinished(); - const payload: ViewRoomPayload = { - action: Action.ViewRoom, - auto_join: autoJoin, - should_peek: shouldPeek, + const cli = MatrixClientPeg.get(); + showRoom(cli, room, { + roomAlias, + autoJoin, + shouldPeek, + roomServer: this.state.roomServer, metricsTrigger: "RoomDirectory", - }; - if (room) { - // 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; - } - } - - if (!roomAlias) { - roomAlias = getDisplayAliasForRoom(room); - } - - payload.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'), - }; - - if (this.state.roomServer) { - payload.via_servers = [this.state.roomServer]; - } - } - // It's not really possible to join Matrix rooms by ID because the HS has no way to know - // which servers to start querying. However, there's no other way to join rooms in - // this list without aliases at present, so if roomAlias isn't set here we have no - // choice but to supply the ID. - if (roomAlias) { - payload.room_alias = roomAlias; - } else { - payload.room_id = room.room_id; - } - dis.dispatch(payload); + }); }; + private stringLooksLikeId(s: string, fieldType: IFieldType) { let pat = /^#[^\s]+:[^\s]/; if (fieldType && fieldType.regexp) { @@ -459,27 +401,11 @@ export default class RoomDirectory extends React.Component { return pat.test(s); } - private getFieldsForThirdPartyLocation(userInput: string, protocol: IProtocol, instance: IInstance) { - // make an object with the fields specified by that protocol. We - // require that the values of all but the last field come from the - // instance. The last is the user input. - const requiredFields = protocol.location_fields; - if (!requiredFields) return null; - const fields = {}; - for (let i = 0; i < requiredFields.length - 1; ++i) { - const thisField = requiredFields[i]; - if (instance.fields[thisField] === undefined) return null; - fields[thisField] = instance.fields[thisField]; - } - fields[requiredFields[requiredFields.length - 1]] = userInput; - return fields; - } - private onFinished = () => { this.props.onFinished(false); }; - render() { + public render() { let content; if (this.state.error) { content = this.state.error; @@ -491,7 +417,7 @@ export default class RoomDirectory extends React.Component { , ); @@ -571,7 +497,7 @@ export default class RoomDirectory extends React.Component { let showJoinButton = this.stringLooksLikeId(this.state.filterString, instanceExpectedFieldType); if (protocolName) { const instance = instanceForInstanceId(this.protocols, this.state.instanceId); - if (this.getFieldsForThirdPartyLocation( + if (getFieldsForThirdPartyLocation( this.state.filterString, this.protocols[protocolName], instance, diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 94212927641..77faf0f9298 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -28,7 +28,7 @@ import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCo import { getKeyBindingsManager } from "../../KeyBindingsManager"; import SpaceStore from "../../stores/spaces/SpaceStore"; import { UPDATE_SELECTED_SPACE } from "../../stores/spaces"; -import { isMac, Key } from "../../Keyboard"; +import { IS_MAC, Key } from "../../Keyboard"; import SettingsStore from "../../settings/SettingsStore"; import Modal from "../../Modal"; import SpotlightDialog from "../views/dialogs/SpotlightDialog"; @@ -206,7 +206,7 @@ export default class RoomSearch extends React.PureComponent { ); let shortcutPrompt =
- { isMac ? "⌘ K" : _t(ALTERNATE_KEY_NAME[Key.CONTROL]) + " K" } + { IS_MAC ? "⌘ K" : _t(ALTERNATE_KEY_NAME[Key.CONTROL]) + " K" }
; if (this.props.isMinimized) { diff --git a/src/components/structures/RoomStatusBar.tsx b/src/components/structures/RoomStatusBar.tsx index 94b9905becc..a89f205a88e 100644 --- a/src/components/structures/RoomStatusBar.tsx +++ b/src/components/structures/RoomStatusBar.tsx @@ -223,7 +223,7 @@ export default class RoomStatusBar extends React.PureComponent { "Please contact your service administrator to continue using the service.", ), 'hs_disabled': _td( - "Your message wasn't sent because this homeserver has been blocked by it's administrator. " + + "Your message wasn't sent because this homeserver has been blocked by its administrator. " + "Please contact your service administrator to continue using the service.", ), '': _td( diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index f53e75db0be..a7a4ec2a9e0 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -65,6 +65,7 @@ import ScrollPanel from "./ScrollPanel"; import TimelinePanel from "./TimelinePanel"; import ErrorBoundary from "../views/elements/ErrorBoundary"; import RoomPreviewBar from "../views/rooms/RoomPreviewBar"; +import RoomPreviewCard from "../views/rooms/RoomPreviewCard"; import SearchBar, { SearchScope } from "../views/rooms/SearchBar"; import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar"; import AuxPanel from "../views/rooms/AuxPanel"; @@ -75,8 +76,7 @@ import EffectsOverlay from "../views/elements/EffectsOverlay"; import { containsEmoji } from '../../effects/utils'; import { CHAT_EFFECTS } from '../../effects'; import WidgetStore from "../../stores/WidgetStore"; -import { getVideoChannel } from "../../utils/VideoChannelUtils"; -import AppTile from "../views/elements/AppTile"; +import VideoRoomView from "./VideoRoomView"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import Notifier from "../../Notifier"; import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast"; @@ -180,9 +180,7 @@ export interface IRoomState { // this is true if we are fully scrolled-down, and are looking at // the end of the live timeline. It has the effect of hiding the // 'scroll to bottom' knob, among a couple of other things. - atEndOfLiveTimeline: boolean; - // used by componentDidUpdate to avoid unnecessary checks - atEndOfLiveTimelineInit: boolean; + atEndOfLiveTimeline?: boolean; showTopUnreadMessagesBar: boolean; statusBarVisible: boolean; // We load this later by asking the js-sdk to suggest a version for us. @@ -258,8 +256,6 @@ export class RoomView extends React.Component { isPeeking: false, showRightPanel: false, joining: false, - atEndOfLiveTimeline: true, - atEndOfLiveTimelineInit: false, showTopUnreadMessagesBar: false, statusBarVisible: false, canReact: false, @@ -693,9 +689,8 @@ export class RoomView extends React.Component { // in render() prevents the ref from being set on first mount, so we try and // catch the messagePanel when it does mount. Because we only want the ref once, // we use a boolean flag to avoid duplicate work. - if (this.messagePanel && !this.state.atEndOfLiveTimelineInit) { + if (this.messagePanel && this.state.atEndOfLiveTimeline === undefined) { this.setState({ - atEndOfLiveTimelineInit: true, atEndOfLiveTimeline: this.messagePanel.isAtEndOfLiveTimeline(), }); } @@ -1249,7 +1244,7 @@ export class RoomView extends React.Component { } }; - private onInviteButtonClick = () => { + private onInviteClick = () => { // open the room inviter dis.dispatch({ action: 'view_invite', @@ -1404,7 +1399,12 @@ export class RoomView extends React.Component { .getServerAggregatedRelation(THREAD_RELATION_TYPE.name); if (!bundledRelationship || event.getThread()) continue; const room = this.context.getRoom(event.getRoomId()); - event.setThread(room.findThreadForEvent(event) ?? room.createThread(event, [], true)); + const thread = room.findThreadForEvent(event); + if (thread) { + event.setThread(thread); + } else { + room.createThread(event.getId(), event, [], true); + } } } } @@ -1832,6 +1832,21 @@ export class RoomView extends React.Component { } const myMembership = this.state.room.getMyMembership(); + if ( + this.state.room.isElementVideoRoom() && + !(SettingsStore.getValue("feature_video_rooms") && myMembership === "join") + ) { + return +
+ +
; +
; + } + // SpaceRoomView handles invites itself if (myMembership === "invite" && !this.state.room.isSpaceRoom()) { if (this.state.joining || this.state.rejecting) { @@ -1904,7 +1919,7 @@ export class RoomView extends React.Component { statusBar = ; @@ -2103,7 +2118,7 @@ export class RoomView extends React.Component { } let jumpToBottom; // Do not show JumpToBottomButton if we have search results showing, it makes no sense - if (!this.state.atEndOfLiveTimeline && !this.state.searchResults) { + if (this.state.atEndOfLiveTimeline === false && !this.state.searchResults) { jumpToBottom = ( 0} numUnreadMessages={this.state.numUnreadMessages} @@ -2169,18 +2184,11 @@ export class RoomView extends React.Component { ; break; case MainSplitContentType.Video: { - const app = getVideoChannel(this.state.room.roomId); - if (!app) break; mainSplitContentClassName = "mx_MainSplit_video"; - mainSplitBody = ; + mainSplitBody = <> + + { previewBar } + ; } } const mainSplitContentClasses = classNames("mx_RoomView_body", mainSplitContentClassName); @@ -2190,6 +2198,7 @@ export class RoomView extends React.Component { let onAppsClick = this.onAppsClick; let onForgetClick = this.onForgetClick; let onSearchClick = this.onSearchClick; + let onInviteClick = null; // Simplify the header for other main split types switch (this.state.mainSplitContentType) { @@ -2212,6 +2221,9 @@ export class RoomView extends React.Component { onAppsClick = null; onForgetClick = null; onSearchClick = null; + if (this.state.room.canInvite(this.context.credentials.userId)) { + onInviteClick = this.onInviteClick; + } } return ( @@ -2227,6 +2239,7 @@ export class RoomView extends React.Component { oobData={this.props.oobData} inRoom={myMembership === 'join'} onSearchClick={onSearchClick} + onInviteClick={onInviteClick} onForgetClick={(myMembership === "leave") ? onForgetClick : null} e2eStatus={this.state.e2eStatus} onAppsClick={this.state.hasPinnedWidgets ? onAppsClick : null} diff --git a/src/components/structures/ScrollPanel.tsx b/src/components/structures/ScrollPanel.tsx index 359c10509d4..5db5e6daad9 100644 --- a/src/components/structures/ScrollPanel.tsx +++ b/src/components/structures/ScrollPanel.tsx @@ -17,34 +17,29 @@ limitations under the License. import React, { createRef, CSSProperties, ReactNode, KeyboardEvent } from "react"; import { logger } from "matrix-js-sdk/src/logger"; +import SettingsStore from '../../settings/SettingsStore'; import Timer from '../../utils/Timer'; import AutoHideScrollbar from "./AutoHideScrollbar"; import { getKeyBindingsManager } from "../../KeyBindingsManager"; import ResizeNotifier from "../../utils/ResizeNotifier"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; -import UIStore, { UI_EVENTS } from "../../stores/UIStore"; - -const DEBUG_SCROLL = false; // The amount of extra scroll distance to allow prior to unfilling. // See getExcessHeight. const UNPAGINATION_PADDING = 6000; -// The number of milliseconds to debounce calls to onUnfillRequest, to prevent -// many scroll events causing many unfilling requests. +// The number of milliseconds to debounce calls to onUnfillRequest, +// to prevent many scroll events causing many unfilling requests. const UNFILL_REQUEST_DEBOUNCE_MS = 200; -// _updateHeight makes the height a ceiled multiple of this so we -// don't have to update the height too often. It also allows the user -// to scroll past the pagination spinner a bit so they don't feel blocked so +// updateHeight makes the height a ceiled multiple of this so we don't have to update the height too often. +// It also allows the user to scroll past the pagination spinner a bit so they don't feel blocked so // much while the content loads. const PAGE_SIZE = 400; -let debuglog; -if (DEBUG_SCROLL) { - // using bind means that we get to keep useful line numbers in the console - debuglog = logger.log.bind(console, "ScrollPanel debuglog:"); -} else { - debuglog = function() {}; -} +const debuglog = (...args: any[]) => { + if (SettingsStore.getValue("debug_scroll_panel")) { + logger.log.call(console, "ScrollPanel debuglog:", ...args); + } +}; interface IProps { /* stickyBottom: if set to true, then once the user hits the bottom of @@ -193,24 +188,20 @@ export default class ScrollPanel extends React.Component { private preventShrinkingState: IPreventShrinkingState; private unfillDebouncer: number; private bottomGrowth: number; - private pages: number; + private minListHeight: number; private heightUpdateInProgress: boolean; private divScroll: HTMLDivElement; constructor(props, context) { super(props, context); - if (this.props.resizeNotifier) { - this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize); - } + this.props.resizeNotifier?.on("middlePanelResizedNoisy", this.onResize); this.resetScrollState(); } componentDidMount() { this.checkScroll(); - - UIStore.instance.on(UI_EVENTS.Resize, this.onUiResize); } componentDidUpdate() { @@ -230,11 +221,7 @@ export default class ScrollPanel extends React.Component { // (We could use isMounted(), but facebook have deprecated that.) this.unmounted = true; - if (this.props.resizeNotifier) { - this.props.resizeNotifier.removeListener("middlePanelResizedNoisy", this.onResize); - } - - UIStore.instance.off(UI_EVENTS.Resize, this.onUiResize); + this.props.resizeNotifier?.removeListener("middlePanelResizedNoisy", this.onResize); } private onScroll = ev => { @@ -546,7 +533,7 @@ export default class ScrollPanel extends React.Component { stuckAtBottom: this.props.startAtBottom, }; this.bottomGrowth = 0; - this.pages = 0; + this.minListHeight = 0; this.scrollTimeout = new Timer(100); this.heightUpdateInProgress = false; }; @@ -721,17 +708,6 @@ export default class ScrollPanel extends React.Component { } } - private onUiResize = () => { - this.setDataScrollbar(); - }; - - private setDataScrollbar(contentHeight = this.getMessagesHeight()) { - const sn = this.getScrollNode(); - const minHeight = sn.clientHeight; - const displayScrollbar = contentHeight > minHeight; - sn.dataset.scrollbar = displayScrollbar.toString(); - } - // need a better name that also indicates this will change scrollTop? Rebalance height? Reveal content? private async updateHeight(): Promise { // wait until user has stopped scrolling @@ -750,10 +726,13 @@ export default class ScrollPanel extends React.Component { const sn = this.getScrollNode(); const itemlist = this.itemlist.current; const contentHeight = this.getMessagesHeight(); - const minHeight = sn.clientHeight; - const height = Math.max(minHeight, contentHeight); - this.pages = Math.ceil(height / PAGE_SIZE); - this.setDataScrollbar(contentHeight); + // Only round to the nearest page when we're basing the height off the content, not off the scrollNode height + // otherwise it'll cause too much overscroll which makes it possible to entirely scroll content off-screen. + if (contentHeight < sn.clientHeight) { + this.minListHeight = sn.clientHeight; + } else { + this.minListHeight = Math.ceil(contentHeight / PAGE_SIZE) * PAGE_SIZE; + } this.bottomGrowth = 0; const newHeight = `${this.getListHeight()}px`; @@ -822,7 +801,7 @@ export default class ScrollPanel extends React.Component { } private getListHeight(): number { - return this.bottomGrowth + (this.pages * PAGE_SIZE); + return this.bottomGrowth + this.minListHeight; } private getMessagesHeight(): number { diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index 756cacab1fe..89790e46746 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -36,7 +36,6 @@ import classNames from "classnames"; import { sortBy, uniqBy } from "lodash"; import { GuestAccess, HistoryVisibility } from "matrix-js-sdk/src/@types/partials"; -import dis from "../../dispatcher/dispatcher"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { _t } from "../../languageHandler"; import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; @@ -330,13 +329,13 @@ export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: st // fail earlier so they don't have to click back to the directory. if (cli.isGuest()) { if (!room.world_readable && !room.guest_can_join) { - dis.dispatch({ action: "require_registration" }); + defaultDispatcher.dispatch({ action: "require_registration" }); return; } } const roomAlias = getDisplayAliasForRoom(room) || undefined; - dis.dispatch({ + defaultDispatcher.dispatch({ action: Action.ViewRoom, should_peek: true, room_alias: roomAlias, @@ -356,7 +355,7 @@ export const joinRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: st // 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 (cli.isGuest()) { - dis.dispatch({ action: "require_registration" }); + defaultDispatcher.dispatch({ action: "require_registration" }); return; } @@ -365,7 +364,7 @@ export const joinRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: st }); prom.then(() => { - dis.dispatch({ + defaultDispatcher.dispatch({ action: Action.JoinRoomReady, roomId, metricsTrigger: "SpaceHierarchy", @@ -569,7 +568,7 @@ const ManageButtons = ({ hierarchy, selected, setSelected, setError }: IManageBu const selectedRelations = Array.from(selected.keys()).flatMap(parentId => { return [ ...selected.get(parentId).values(), - ].map(childId => [parentId, childId]) as [string, string][]; + ].map(childId => [parentId, childId]); }); const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => { diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index aaf3e4e1358..695fa7a749e 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -26,17 +26,13 @@ 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 { useFeatureEnabled } from "../../hooks/useSettings"; import createRoom, { IOpts } from "../../createRoom"; import Field from "../views/elements/Field"; -import { useTypedEventEmitter } from "../../hooks/useEventEmitter"; import withValidation from "../views/elements/Validation"; import * as Email from "../../email"; import defaultDispatcher from "../../dispatcher/dispatcher"; -import dis from "../../dispatcher/dispatcher"; import { Action } from "../../dispatcher/actions"; import ResizeNotifier from "../../utils/ResizeNotifier"; import MainSplit from './MainSplit'; @@ -57,8 +53,7 @@ import { showSpaceSettings, } from "../../utils/space"; import SpaceHierarchy, { showRoom } from "./SpaceHierarchy"; -import MemberAvatar from "../views/avatars/MemberAvatar"; -import FacePile from "../views/elements/FacePile"; +import RoomFacePile from "../views/elements/RoomFacePile"; import { AddExistingToSpace, defaultDmsRenderer, @@ -71,11 +66,10 @@ import IconizedContextMenu, { } from "../views/context_menus/IconizedContextMenu"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { BetaPill } from "../views/beta/BetaCard"; -import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership"; import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu"; -import { useAsyncMemo } from "../../hooks/useAsyncMemo"; -import { useDispatcher } from "../../hooks/useDispatcher"; -import { useRoomState } from "../../hooks/useRoomState"; +import RoomInfoLine from "../views/rooms/RoomInfoLine"; +import RoomPreviewCard from "../views/rooms/RoomPreviewCard"; +import { useMyRoomMembership } from "../../hooks/useRoomMembers"; import { shouldShowComponent } from "../../customisations/helpers/UIComponents"; import { UIComponent } from "../../settings/UIFeature"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; @@ -107,205 +101,6 @@ enum Phase { PrivateExistingRooms, } -const RoomMemberCount = ({ room, children }) => { - const members = useRoomMembers(room); - const count = members.length; - - if (children) return children(count); - return count; -}; - -const useMyRoomMembership = (room: Room) => { - const [membership, setMembership] = useState(room.getMyMembership()); - useTypedEventEmitter(room, RoomEvent.MyMembership, () => { - setMembership(room.getMyMembership()); - }); - return membership; -}; - -const SpaceInfo = ({ space }: { space: Room }) => { - // summary will begin as undefined whilst loading and go null if it fails to load or we are not invited. - const summary = useAsyncMemo(async () => { - if (space.getMyMembership() !== "invite") return null; - try { - return space.client.getRoomSummary(space.roomId); - } catch (e) { - return null; - } - }, [space]); - const joinRule = useRoomState(space, state => state.getJoinRule()); - const membership = useMyRoomMembership(space); - - let visibilitySection; - if (joinRule === JoinRule.Public) { - visibilitySection = - { _t("Public space") } - ; - } else { - visibilitySection = - { _t("Private space") } - ; - } - - let memberSection; - if (membership === "invite" && summary) { - // Don't trust local state and instead use the summary API - memberSection = - { _t("%(count)s members", { count: summary.num_joined_members }) } - ; - } else if (summary !== undefined) { // summary is not still loading - memberSection = - { (count) => count > 0 ? ( - { - RightPanelStore.instance.setCard({ phase: RightPanelPhases.SpaceMemberList }); - }} - > - { _t("%(count)s members", { count }) } - - ) : null } - ; - } - - return
- { visibilitySection } - { memberSection } -
; -}; - -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 joinRule = useRoomState(space, state => state.getJoinRule()); - const cannotJoin = getEffectiveMembership(myMembership) === EffectiveMembership.Leave - && joinRule !== JoinRule.Public; - - let inviterSection; - let joinButtons; - if (myMembership === "join") { - // XXX remove this when spaces leaves Beta - joinButtons = ( - { - dis.dispatch({ - action: "leave_room", - room_id: space.roomId, - }); - }} - > - { _t("Leave") } - - ); - } else if (myMembership === "invite") { - const inviteSender = space.getMember(cli.getUserId())?.events.member?.getSender(); - const inviter = inviteSender && space.getMember(inviteSender); - - if (inviteSender) { - inviterSection =
- -
-
- { _t(" invites you", {}, { - inviter: () => { inviter?.name || inviteSender }, - }) } -
- { inviter ?
- { inviteSender } -
: null } -
-
; - } - - joinButtons = <> - { - setBusy(true); - onRejectButtonClicked(); - }} - > - { _t("Reject") } - - { - setBusy(true); - onJoinButtonClicked(); - }} - > - { _t("Accept") } - - ; - } else { - joinButtons = ( - { - onJoinButtonClicked(); - if (!cli.isGuest()) { - // user will be shown a modal that won't fire a room join error - setBusy(true); - } - }} - disabled={cannotJoin} - > - { _t("Join") } - - ); - } - - if (busy) { - joinButtons = ; - } - - let footer; - if (cannotJoin) { - footer =
- { _t("To view %(spaceName)s, you need an invite", { - spaceName: space.name, - }) } -
; - } - - return
- { inviterSection } - -

- -

- - - { (topic, ref) => -
- { topic } -
- } -
- { space.getJoinRule() === "public" && } -
- { joinButtons } -
- { footer } -
; -}; - const SpaceLandingAddButton = ({ space }) => { const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); const canCreateRoom = shouldShowComponent(UIComponent.CreateRooms); @@ -316,8 +111,8 @@ const SpaceLandingAddButton = ({ space }) => { if (menuDisplayed) { const rect = handle.current.getBoundingClientRect(); contextMenu = { }} /> { videoRoomsEnabled && { e.preventDefault(); @@ -452,9 +247,9 @@ const SpaceLanding = ({ space }: { space: Room }) => {
- +
- + { inviteButton } { settingsButton }
@@ -847,8 +642,8 @@ export default class SpaceRoomView extends React.PureComponent { if (this.state.myMembership === "join") { return ; } else { - return ; diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx index 1dd37a8c412..a84013bb221 100644 --- a/src/components/structures/ThreadPanel.tsx +++ b/src/components/structures/ThreadPanel.tsx @@ -39,6 +39,7 @@ import BetaFeedbackDialog from '../views/dialogs/BetaFeedbackDialog'; import { Action } from '../../dispatcher/actions'; import { UserTab } from '../views/dialogs/UserTab'; import dis from '../../dispatcher/dispatcher'; +import Spinner from "../views/elements/Spinner"; interface IProps { roomId: string; @@ -163,7 +164,9 @@ const EmptyThread: React.FC = ({ hasThreads, filterOption, sh body = <>

{ _t("Threads help keep your conversations on-topic and easy to track.") }

- { _t('Tip: Use "Reply in thread" when hovering over a message.', {}, { + { _t('Tip: Use “%(replyInThread)s” when hovering over a message.', { + replyInThread: _t("Reply in thread"), + }, { b: sub => { sub }, }) }

@@ -189,37 +192,27 @@ const ThreadPanel: React.FC = ({ const [filterOption, setFilterOption] = useState(ThreadFilterType.All); const [room, setRoom] = useState(null); - const [threadCount, setThreadCount] = useState(0); const [timelineSet, setTimelineSet] = useState(null); const [narrow, setNarrow] = useState(false); useEffect(() => { const room = mxClient.getRoom(roomId); room.createThreadsTimelineSets().then(() => { - setRoom(room); + return room.fetchRoomThreads(); + }).then(() => { setFilterOption(ThreadFilterType.All); - room.fetchRoomThreads(); + setRoom(room); }); }, [mxClient, roomId]); useEffect(() => { - function onNewThread(): void { - setThreadCount(room.threads.size); - } - function refreshTimeline() { - if (timelineSet) timelinePanel.current.refreshTimeline(); + timelinePanel?.current.refreshTimeline(); } - if (room) { - setThreadCount(room.threads.size); - - room.on(ThreadEvent.New, onNewThread); - room.on(ThreadEvent.Update, refreshTimeline); - } + room?.on(ThreadEvent.Update, refreshTimeline); return () => { - room?.removeListener(ThreadEvent.New, onNewThread); room?.removeListener(ThreadEvent.Update, refreshTimeline); }; }, [room, mxClient, timelineSet]); @@ -257,7 +250,7 @@ const ThreadPanel: React.FC = ({ header={} footer={<> = ({ sensor={card.current} onMeasurement={setNarrow} /> - { timelineSet && ( - = ({ permalinkCreator={permalinkCreator} disableGrouping={true} /> - ) } + :
+ +
+ } ); diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index 12dd84685d9..2eb4af94fcd 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React, { createRef, KeyboardEvent } from 'react'; -import { Thread, ThreadEvent, THREAD_RELATION_TYPE } from 'matrix-js-sdk/src/models/thread'; +import { Thread, THREAD_RELATION_TYPE, ThreadEvent } from 'matrix-js-sdk/src/models/thread'; import { Room } from 'matrix-js-sdk/src/models/room'; import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { TimelineWindow } from 'matrix-js-sdk/src/timeline-window'; @@ -51,6 +51,7 @@ import Measured from '../views/elements/Measured'; import PosthogTrackers from "../../PosthogTrackers"; import { ButtonEvent } from "../views/elements/AccessibleButton"; import { RoomViewStore } from '../../stores/RoomViewStore'; +import Spinner from "../views/elements/Spinner"; interface IProps { room: Room; @@ -66,7 +67,6 @@ interface IProps { interface IState { thread?: Thread; - lastThreadReply?: MatrixEvent; layout: Layout; editState?: EditorStateTransfer; replyToEvent?: MatrixEvent; @@ -104,7 +104,6 @@ export default class ThreadView extends React.Component { } public componentWillUnmount(): void { - this.teardownThread(); if (this.dispatcherRef) dis.unregister(this.dispatcherRef); const roomId = this.props.mxEvent.getRoomId(); const room = MatrixClientPeg.get().getRoom(roomId); @@ -123,7 +122,6 @@ export default class ThreadView extends React.Component { public componentDidUpdate(prevProps) { if (prevProps.mxEvent !== this.props.mxEvent) { - this.teardownThread(); this.setupThread(this.props.mxEvent); } @@ -134,7 +132,6 @@ export default class ThreadView extends React.Component { private onAction = (payload: ActionPayload): void => { if (payload.phase == RightPanelPhases.ThreadView && payload.event) { - this.teardownThread(); this.setupThread(payload.event); } switch (payload.action) { @@ -164,23 +161,15 @@ export default class ThreadView extends React.Component { }; private setupThread = (mxEv: MatrixEvent) => { - let thread = this.props.room.threads?.get(mxEv.getId()); + let thread = this.props.room.getThread(mxEv.getId()); if (!thread) { - thread = this.props.room.createThread(mxEv, [mxEv], true); + thread = this.props.room.createThread(mxEv.getId(), mxEv, [mxEv], true); } - thread.on(ThreadEvent.Update, this.updateLastThreadReply); this.updateThread(thread); }; - private teardownThread = () => { - if (this.state.thread) { - this.state.thread.removeListener(ThreadEvent.Update, this.updateLastThreadReply); - } - }; - private onNewThread = (thread: Thread) => { if (thread.id === this.props.mxEvent.getId()) { - this.teardownThread(); this.setupThread(this.props.mxEvent); } }; @@ -189,33 +178,15 @@ export default class ThreadView extends React.Component { if (thread && this.state.thread !== thread) { this.setState({ thread, - lastThreadReply: thread.lastReply((ev: MatrixEvent) => { - return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status; - }), }, async () => { thread.emit(ThreadEvent.ViewThread); - if (!thread.initialEventsFetched) { - const response = await thread.fetchInitialEvents(); - if (response?.nextBatch) { - this.nextBatch = response.nextBatch; - } - } - + await thread.fetchInitialEvents(); + this.nextBatch = thread.liveTimeline.getPaginationToken(Direction.Backward); this.timelinePanel.current?.refreshTimeline(); }); } }; - private updateLastThreadReply = () => { - if (this.state.thread) { - this.setState({ - lastThreadReply: this.state.thread.lastReply((ev: MatrixEvent) => { - return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status; - }), - }); - } - }; - private resetJumpToEvent = (event?: string): void => { if (this.props.initialEvent && this.props.initialEventScrollIntoView && event === this.props.initialEvent?.getId()) { @@ -298,12 +269,16 @@ export default class ThreadView extends React.Component { }; private get threadRelation(): IEventRelation { + const lastThreadReply = this.state.thread?.lastReply((ev: MatrixEvent) => { + return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status; + }); + return { "rel_type": THREAD_RELATION_TYPE.name, "event_id": this.state.thread?.id, "is_falling_back": true, "m.in_reply_to": { - "event_id": this.state.lastThreadReply?.getId() ?? this.state.thread?.id, + "event_id": lastThreadReply?.getId() ?? this.state.thread?.id, }, }; } @@ -324,11 +299,45 @@ export default class ThreadView extends React.Component { const threadRelation = this.threadRelation; - const messagePanelClassNames = classNames( - "mx_RoomView_messagePanel", - { - "mx_GroupLayout": this.state.layout === Layout.Group, - }); + const messagePanelClassNames = classNames("mx_RoomView_messagePanel", { + "mx_GroupLayout": this.state.layout === Layout.Group, + }); + + let timeline: JSX.Element; + if (this.state.thread) { + timeline = <> + +
- { customStatusSection } { topSection } { primaryOptionList }
; @@ -516,11 +407,6 @@ export default class UserMenu extends React.Component { const displayName = OwnProfileStore.instance.displayName || userId; const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize); - let badge: JSX.Element; - if (this.state.dndEnabled) { - badge =
; - } - let name: JSX.Element; if (!this.props.isPanelCollapsed) { name =
@@ -535,9 +421,6 @@ export default class UserMenu extends React.Component { label={_t("User menu")} isExpanded={!!this.state.contextMenuPosition} onContextMenu={this.onContextMenu} - className={classNames({ - mx_UserMenu_cutout: badge, - })} >
{ resizeMethod="crop" className="mx_UserMenu_userAvatar_BaseAvatar" /> - { badge }
{ name } diff --git a/src/components/structures/VideoRoomView.tsx b/src/components/structures/VideoRoomView.tsx new file mode 100644 index 00000000000..e2cd62e08d0 --- /dev/null +++ b/src/components/structures/VideoRoomView.tsx @@ -0,0 +1,86 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { FC, useContext, useState, useMemo } from "react"; +import { logger } from "matrix-js-sdk/src/logger"; +import { Room } from "matrix-js-sdk/src/models/room"; + +import MatrixClientContext from "../../contexts/MatrixClientContext"; +import { useEventEmitter } from "../../hooks/useEventEmitter"; +import WidgetUtils from "../../utils/WidgetUtils"; +import { addVideoChannel, getVideoChannel } from "../../utils/VideoChannelUtils"; +import WidgetStore, { IApp } from "../../stores/WidgetStore"; +import { UPDATE_EVENT } from "../../stores/AsyncStore"; +import VideoChannelStore, { VideoChannelEvent } from "../../stores/VideoChannelStore"; +import AppTile from "../views/elements/AppTile"; +import VideoLobby from "../views/voip/VideoLobby"; + +interface IProps { + room: Room; + resizing: boolean; +} + +const VideoRoomView: FC = ({ room, resizing }) => { + const cli = useContext(MatrixClientContext); + const store = VideoChannelStore.instance; + + // In case we mount before the WidgetStore knows about our Jitsi widget + const [widgetStoreReady, setWidgetStoreReady] = useState(Boolean(WidgetStore.instance.matrixClient)); + const [widgetLoaded, setWidgetLoaded] = useState(false); + useEventEmitter(WidgetStore.instance, UPDATE_EVENT, (roomId: string) => { + if (roomId === null) setWidgetStoreReady(true); + if (roomId === null || roomId === room.roomId) { + setWidgetLoaded(Boolean(getVideoChannel(room.roomId))); + } + }); + + const app: IApp = useMemo(() => { + if (widgetStoreReady) { + const app = getVideoChannel(room.roomId); + if (!app) { + logger.warn(`No video channel for room ${room.roomId}`); + // Since widgets in video rooms are mutable, we'll take this opportunity to + // reinstate the Jitsi widget in case another client removed it + if (WidgetUtils.canUserModifyWidgets(room.roomId)) { + addVideoChannel(room.roomId, room.name); + } + } + return app; + } + }, [room, widgetStoreReady, widgetLoaded]); // eslint-disable-line react-hooks/exhaustive-deps + + const [connected, setConnected] = useState(store.connected && store.roomId === room.roomId); + useEventEmitter(store, VideoChannelEvent.Connect, () => setConnected(store.roomId === room.roomId)); + useEventEmitter(store, VideoChannelEvent.Disconnect, () => setConnected(false)); + + if (!app) return null; + + return
+ { connected ? null : } + { /* We render the widget even if we're disconnected, so it stays loaded */ } + +
; +}; + +export default VideoRoomView; diff --git a/src/components/structures/ViewSource.tsx b/src/components/structures/ViewSource.tsx index e84a26409e2..c8628a7f96f 100644 --- a/src/components/structures/ViewSource.tsx +++ b/src/components/structures/ViewSource.tsx @@ -167,8 +167,16 @@ export default class ViewSource extends React.Component { return (
-
{ _t("Room ID: %(roomId)s", { roomId }) }
-
{ _t("Event ID: %(eventId)s", { eventId }) }
+
+ roomId} border={false}> + { _t("Room ID: %(roomId)s", { roomId }) } + +
+
+ eventId} border={false}> + { _t("Event ID: %(eventId)s", { eventId }) } + +
{ isEditing ? this.editSourceContent() : this.viewSourceContent() }
diff --git a/src/components/structures/auth/ForgotPassword.tsx b/src/components/structures/auth/ForgotPassword.tsx index 58b0073c443..df39f0aa8f2 100644 --- a/src/components/structures/auth/ForgotPassword.tsx +++ b/src/components/structures/auth/ForgotPassword.tsx @@ -19,6 +19,7 @@ limitations under the License. import React from 'react'; import classNames from 'classnames'; import { logger } from "matrix-js-sdk/src/logger"; +import { createClient } from "matrix-js-sdk/src/matrix"; import { _t, _td } from '../../../languageHandler'; import Modal from "../../../Modal"; @@ -37,6 +38,7 @@ import AuthHeader from "../../views/auth/AuthHeader"; import AuthBody from "../../views/auth/AuthBody"; import PassphraseConfirmField from "../../views/auth/PassphraseConfirmField"; import AccessibleButton from '../../views/elements/AccessibleButton'; +import StyledCheckbox from '../../views/elements/StyledCheckbox'; enum Phase { // Show the forgot password inputs @@ -72,6 +74,9 @@ interface IState { serverDeadError: string; currentHttpRequest?: Promise; + + serverSupportsControlOfDevicesLogout: boolean; + logoutDevices: boolean; } enum ForgotPasswordField { @@ -97,11 +102,14 @@ export default class ForgotPassword extends React.Component { serverIsAlive: true, serverErrorIsFatal: false, serverDeadError: "", + serverSupportsControlOfDevicesLogout: false, + logoutDevices: false, }; public componentDidMount() { this.reset = null; this.checkServerLiveliness(this.props.serverConfig); + this.checkServerCapabilities(this.props.serverConfig); } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event @@ -112,6 +120,9 @@ export default class ForgotPassword extends React.Component { // Do a liveliness check on the new URLs this.checkServerLiveliness(newProps.serverConfig); + + // Do capabilities check on new URLs + this.checkServerCapabilities(newProps.serverConfig); } private async checkServerLiveliness(serverConfig): Promise { @@ -129,12 +140,25 @@ export default class ForgotPassword extends React.Component { } } - public submitPasswordReset(email: string, password: string): void { + private async checkServerCapabilities(serverConfig: ValidatedServerConfig): Promise { + const tempClient = createClient({ + baseUrl: serverConfig.hsUrl, + }); + + const serverSupportsControlOfDevicesLogout = await tempClient.doesServerSupportLogoutDevices(); + + this.setState({ + logoutDevices: !serverSupportsControlOfDevicesLogout, + serverSupportsControlOfDevicesLogout, + }); + } + + public submitPasswordReset(email: string, password: string, logoutDevices = true): void { this.setState({ phase: Phase.SendingEmail, }); this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl); - this.reset.resetPassword(email, password).then(() => { + this.reset.resetPassword(email, password, logoutDevices).then(() => { this.setState({ phase: Phase.EmailSent, }); @@ -174,24 +198,35 @@ export default class ForgotPassword extends React.Component { return; } - Modal.createTrackedDialog('Forgot Password Warning', '', QuestionDialog, { - title: _t('Warning!'), - description: -
- { _t( - "Changing your password will reset any end-to-end encryption keys " + - "on all of your sessions, making encrypted chat history unreadable. Set up " + - "Key Backup or export your room keys from another session before resetting your " + - "password.", - ) } -
, - button: _t('Continue'), - onFinished: (confirmed) => { - if (confirmed) { - this.submitPasswordReset(this.state.email, this.state.password); - } - }, - }); + if (this.state.logoutDevices) { + const { finished } = Modal.createTrackedDialog<[boolean]>('Forgot Password Warning', '', QuestionDialog, { + title: _t('Warning!'), + description: +
+

{ !this.state.serverSupportsControlOfDevicesLogout ? + _t( + "Resetting your password on this homeserver will cause all of your devices to be " + + "signed out. This will delete the message encryption keys stored on them, " + + "making encrypted chat history unreadable.", + ) : + _t( + "Signing out your devices will delete the message encryption keys stored on them, " + + "making encrypted chat history unreadable.", + ) + }

+

{ _t( + "If you want to retain access to your chat history in encrypted rooms, set up Key Backup " + + "or export your message keys from one of your other devices before proceeding.", + ) }

+
, + button: _t('Continue'), + }); + const [confirmed] = await finished; + + if (!confirmed) return; + } + + this.submitPasswordReset(this.state.email, this.state.password, this.state.logoutDevices); }; private async verifyFieldsBeforeSubmit() { @@ -222,8 +257,10 @@ export default class ForgotPassword extends React.Component { } private onInputChanged = (stateKey: string, ev: React.FormEvent) => { + let value = ev.currentTarget.value; + if (stateKey === "email") value = value.trim(); this.setState({ - [stateKey]: ev.currentTarget.value, + [stateKey]: value, } as any); }; @@ -314,6 +351,13 @@ export default class ForgotPassword extends React.Component { autoComplete="new-password" />
+ { this.state.serverSupportsControlOfDevicesLogout ? +
+ this.setState({ logoutDevices: !this.state.logoutDevices })} checked={this.state.logoutDevices}> + { _t("Sign out all devices") } + +
: null + } { _t( 'A verification email will be sent to your inbox to confirm ' + 'setting your new password.', @@ -353,11 +397,14 @@ export default class ForgotPassword extends React.Component { renderDone() { return

{ _t("Your password has been reset.") }

-

{ _t( - "You have been logged out of all sessions and will no longer receive " + - "push notifications. To re-enable notifications, sign in again on each " + - "device.", - ) }

+ { this.state.logoutDevices ? +

{ _t( + "You have been logged out of all devices and will no longer receive " + + "push notifications. To re-enable notifications, sign in again on each " + + "device.", + ) }

+ : null + } 'm.login.password': this.renderPasswordStep, // CAS and SSO are the same thing, modulo the url we link to + // eslint-disable-next-line @typescript-eslint/naming-convention 'm.login.cas': () => this.renderSsoStep("cas"), + // eslint-disable-next-line @typescript-eslint/naming-convention 'm.login.sso': () => this.renderSsoStep("sso"), }; } @@ -220,7 +222,7 @@ export default class LoginComponent extends React.PureComponent "This homeserver has hit its Monthly Active User limit.", ), 'hs_blocked': _td( - "This homeserver has been blocked by it's administrator.", + "This homeserver has been blocked by its administrator.", ), '': _td( "This homeserver has exceeded one of its resource limits.", diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx index ecb4691e1b6..e694fdee40b 100644 --- a/src/components/structures/auth/Registration.tsx +++ b/src/components/structures/auth/Registration.tsx @@ -110,7 +110,9 @@ interface IState { } export default class Registration extends React.Component { - loginLogic: Login; + private readonly loginLogic: Login; + // `replaceClient` tracks latest serverConfig to spot when it changes under the async method which fetches flows + private latestServerConfig: ValidatedServerConfig; constructor(props) { super(props); @@ -137,8 +139,21 @@ export default class Registration extends React.Component { componentDidMount() { this.replaceClient(this.props.serverConfig); + //triggers a confirmation dialog for data loss before page unloads/refreshes + window.addEventListener("beforeunload", this.unloadCallback); } + componentWillUnmount() { + window.removeEventListener("beforeunload", this.unloadCallback); + } + + private unloadCallback = (event: BeforeUnloadEvent) => { + if (this.state.doingUIAuth) { + event.preventDefault(); + event.returnValue = ""; + return ""; + } + }; // TODO: [REACT-WARNING] Replace with appropriate lifecycle event // eslint-disable-next-line UNSAFE_componentWillReceiveProps(newProps) { @@ -149,26 +164,28 @@ export default class Registration extends React.Component { } private async replaceClient(serverConfig: ValidatedServerConfig) { + this.latestServerConfig = serverConfig; + const { hsUrl, isUrl } = serverConfig; + this.setState({ errorText: null, serverDeadError: null, serverErrorIsFatal: false, - // busy while we do liveness check (we need to avoid trying to render + // busy while we do live-ness check (we need to avoid trying to render // the UI auth component while we don't have a matrix client) busy: true, }); // Do a liveliness check on the URLs try { - await AutoDiscoveryUtils.validateServerConfigWithStaticUrls( - serverConfig.hsUrl, - serverConfig.isUrl, - ); + await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl); + if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us this.setState({ serverIsAlive: true, serverErrorIsFatal: false, }); } catch (e) { + if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us this.setState({ busy: false, ...AutoDiscoveryUtils.authComponentStateForError(e, "register"), @@ -178,7 +195,6 @@ export default class Registration extends React.Component { } } - const { hsUrl, isUrl } = serverConfig; const cli = createClient({ baseUrl: hsUrl, idBaseUrl: isUrl, @@ -190,8 +206,10 @@ export default class Registration extends React.Component { let ssoFlow: ISSOFlow; try { const loginFlows = await this.loginLogic.getFlows(); + if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us ssoFlow = loginFlows.find(f => f.type === "m.login.sso" || f.type === "m.login.cas") as ISSOFlow; } catch (e) { + if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us logger.error("Failed to get login flows to check for SSO support", e); } @@ -200,23 +218,19 @@ export default class Registration extends React.Component { ssoFlow, busy: false, }); - const showGenericError = (e) => { - this.setState({ - errorText: _t("Unable to query for supported registration methods."), - // add empty flows array to get rid of spinner - flows: [], - }); - }; + try { // We do the first registration request ourselves to discover whether we need to // do SSO instead. If we've already started the UI Auth process though, we don't // need to. if (!this.state.doingUIAuth) { await this.makeRegisterRequest(null); + if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us // This should never succeed since we specified no auth object. logger.log("Expecting 401 from register request but got success!"); } } catch (e) { + if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us if (e.httpStatus === 401) { this.setState({ flows: e.data.flows, @@ -239,16 +253,20 @@ export default class Registration extends React.Component { } } else { logger.log("Unable to query for supported registration methods.", e); - showGenericError(e); + this.setState({ + errorText: _t("Unable to query for supported registration methods."), + // add empty flows array to get rid of spinner + flows: [], + }); } } } - private onFormSubmit = async (formVals): Promise => { + private onFormSubmit = async (formVals: Record): Promise => { this.setState({ errorText: "", busy: true, - formVals: formVals, + formVals, doingUIAuth: true, }); }; @@ -277,7 +295,7 @@ export default class Registration extends React.Component { response.data.admin_contact, { 'monthly_active_user': _td("This homeserver has hit its Monthly Active User limit."), - 'hs_blocked': _td("This homeserver has been blocked by it's administrator."), + 'hs_blocked': _td("This homeserver has been blocked by its administrator."), '': _td("This homeserver has exceeded one of its resource limits."), }, ); diff --git a/src/components/views/auth/RegistrationForm.tsx b/src/components/views/auth/RegistrationForm.tsx index b57ddf50093..83ee70a7053 100644 --- a/src/components/views/auth/RegistrationForm.tsx +++ b/src/components/views/auth/RegistrationForm.tsx @@ -33,6 +33,7 @@ import Field from '../elements/Field'; import RegistrationEmailPromptDialog from '../dialogs/RegistrationEmailPromptDialog'; import CountryDropdown from "./CountryDropdown"; import PassphraseConfirmField from "./PassphraseConfirmField"; +import { PosthogAnalytics } from '../../../PosthogAnalytics'; enum RegistrationField { Email = "field_email", @@ -147,6 +148,8 @@ export default class RegistrationForm extends React.PureComponent; className?: string; + tabIndex?: number; } const calculateUrls = (url, urls, lowBandwidth) => { diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx index c5dd1bfd3c4..76dc9e69621 100644 --- a/src/components/views/avatars/MemberAvatar.tsx +++ b/src/components/views/avatars/MemberAvatar.tsx @@ -30,7 +30,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; interface IProps extends Omit, "name" | "idName" | "url"> { - member: RoomMember; + member: RoomMember | null; fallbackUserId?: string; width: number; height: number; @@ -43,6 +43,7 @@ interface IProps extends Omit, "name" | title?: string; style?: any; forceHistorical?: boolean; // true to deny `feature_use_only_current_profiles` usage. Default false. + hideTitle?: boolean; } interface IState { @@ -106,8 +107,16 @@ export default class MemberAvatar extends React.PureComponent { } render() { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - let { member, fallbackUserId, onClick, viewUserOnClick, forceHistorical, ...otherProps } = this.props; + let { + member, + fallbackUserId, + onClick, + viewUserOnClick, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + forceHistorical, + hideTitle, + ...otherProps + } = this.props; const userId = member ? member.userId : fallbackUserId; if (viewUserOnClick) { @@ -124,7 +133,7 @@ export default class MemberAvatar extends React.PureComponent { = ({ beacon }) => { + const latestLocationState = useEventEmitterState( + beacon, + BeaconEvent.LocationUpdate, + () => beacon.latestLocationState, + ); + const matrixClient = useContext(MatrixClientContext); + const room = matrixClient.getRoom(beacon.roomId); + + if (!latestLocationState || !beacon.isLive) { + return null; + } + + const isSelfLocation = beacon.beaconInfo.assetType === LocationAssetType.Self; + const beaconMember = isSelfLocation ? + room.getMember(beacon.beaconInfoOwner) : + undefined; + + const humanizedUpdateTime = humanizeTime(latestLocationState.timestamp); + + return
  • + { isSelfLocation ? + : + + } +
    + + latestLocationState?.uri} + /> + + { _t("Updated %(humanizedUpdateTime)s", { humanizedUpdateTime }) } +
    +
  • ; +}; + +export default BeaconListItem; diff --git a/src/components/views/beacon/BeaconMarker.tsx b/src/components/views/beacon/BeaconMarker.tsx index 8c176ab9c07..f7f284b88ed 100644 --- a/src/components/views/beacon/BeaconMarker.tsx +++ b/src/components/views/beacon/BeaconMarker.tsx @@ -58,6 +58,7 @@ const BeaconMarker: React.FC = ({ map, beacon }) => { id={beacon.identifier} geoUri={geoUri} roomMember={markerRoomMember} + useMemberColor />; }; diff --git a/src/components/views/beacon/BeaconStatus.tsx b/src/components/views/beacon/BeaconStatus.tsx index c9d7bd3762d..935e22f4f0b 100644 --- a/src/components/views/beacon/BeaconStatus.tsx +++ b/src/components/views/beacon/BeaconStatus.tsx @@ -28,6 +28,7 @@ import { formatTime } from '../../../DateUtils'; interface Props { displayStatus: BeaconDisplayStatus; displayLiveTimeRemaining?: boolean; + withIcon?: boolean; beacon?: Beacon; label?: string; } @@ -45,6 +46,7 @@ const BeaconStatus: React.FC> = label, className, children, + withIcon, ...rest }) => { const isIdle = displayStatus === BeaconDisplayStatus.Loading || @@ -54,11 +56,11 @@ const BeaconStatus: React.FC> = {...rest} className={classNames('mx_BeaconStatus', `mx_BeaconStatus_${displayStatus}`, className)} > - + /> }
    { displayStatus === BeaconDisplayStatus.Loading && { _t('Loading live location...') } } @@ -68,7 +70,7 @@ const BeaconStatus: React.FC> = { displayStatus === BeaconDisplayStatus.Active && beacon && <> <> - { label } + { label } { displayLiveTimeRemaining ? : diff --git a/src/components/views/beacon/BeaconViewDialog.tsx b/src/components/views/beacon/BeaconViewDialog.tsx index 052a456fe69..e6c4a423fe9 100644 --- a/src/components/views/beacon/BeaconViewDialog.tsx +++ b/src/components/views/beacon/BeaconViewDialog.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { useState, useRef } from 'react'; import { MatrixClient } from 'matrix-js-sdk/src/client'; import { Beacon, @@ -22,6 +22,7 @@ import { } from 'matrix-js-sdk/src/matrix'; import maplibregl from 'maplibre-gl'; +import { Icon as LiveLocationIcon } from '../../../../res/img/location/live-location.svg'; import { useLiveBeacons } from '../../../utils/beacon/useLiveBeacons'; import MatrixClientContext from '../../../contexts/MatrixClientContext'; import BaseDialog from "../dialogs/BaseDialog"; @@ -29,29 +30,57 @@ import { IDialogProps } from "../dialogs/IDialogProps"; import Map from '../location/Map'; import ZoomButtons from '../location/ZoomButtons'; import BeaconMarker from './BeaconMarker'; +import { Bounds, getBeaconBounds } from '../../../utils/beacon/bounds'; +import { getGeoUri } from '../../../utils/beacon'; +import { Icon as LocationIcon } from '../../../../res/img/element-icons/location.svg'; +import { _t } from '../../../languageHandler'; +import AccessibleButton from '../elements/AccessibleButton'; +import DialogSidebar from './DialogSidebar'; +import DialogOwnBeaconStatus from './DialogOwnBeaconStatus'; interface IProps extends IDialogProps { roomId: Room['roomId']; matrixClient: MatrixClient; + // open the map centered on this beacon's location + focusBeacon?: Beacon; } -// TODO actual center is coming soon -// for now just center around first beacon in list -const getMapCenterUri = (beacons: Beacon[]): string => { - const firstBeaconWithLocation = beacons.find(beacon => beacon.latestLocationState); +const getBoundsCenter = (bounds: Bounds): string | undefined => { + if (!bounds) { + return; + } + return getGeoUri({ + latitude: (bounds.north + bounds.south) / 2, + longitude: (bounds.east + bounds.west) / 2, + timestamp: Date.now(), + }); +}; - return firstBeaconWithLocation?.latestLocationState?.uri; +const useInitialMapPosition = (liveBeacons: Beacon[], focusBeacon?: Beacon): { + bounds?: Bounds; centerGeoUri: string; +} => { + const bounds = useRef(getBeaconBounds(liveBeacons)); + const centerGeoUri = useRef( + focusBeacon?.latestLocationState?.uri || + getBoundsCenter(bounds.current), + ); + return { bounds: bounds.current, centerGeoUri: centerGeoUri.current }; }; /** * Dialog to view live beacons maximised */ -const BeaconViewDialog: React.FC = ({ roomId, matrixClient, onFinished }) => { +const BeaconViewDialog: React.FC = ({ + focusBeacon, + roomId, + matrixClient, + onFinished, +}) => { const liveBeacons = useLiveBeacons(roomId, matrixClient); - const mapCenterUri = getMapCenterUri(liveBeacons); - // TODO probably show loader or placeholder when there is no location - // to center the map on + const [isSidebarOpen, setSidebarOpen] = useState(false); + + const { bounds, centerGeoUri } = useInitialMapPosition(liveBeacons, focusBeacon); return ( = ({ roomId, matrixClient, onFinished } fixedWidth={false} > - @@ -77,7 +107,35 @@ const BeaconViewDialog: React.FC = ({ roomId, matrixClient, onFinished } } - + : +
    + + { _t('No live locations') } + + { _t('Close') } + +
    + } + { isSidebarOpen ? + setSidebarOpen(false)} /> : + setSidebarOpen(true)} + data-test-id='beacon-view-dialog-open-sidebar' + className='mx_BeaconViewDialog_viewListButton' + > +   + { _t('View list') } + + } +
    ); diff --git a/src/components/views/beacon/DialogOwnBeaconStatus.tsx b/src/components/views/beacon/DialogOwnBeaconStatus.tsx new file mode 100644 index 00000000000..6ae1e8f5b8f --- /dev/null +++ b/src/components/views/beacon/DialogOwnBeaconStatus.tsx @@ -0,0 +1,80 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useContext } from 'react'; +import { Room, Beacon } from 'matrix-js-sdk/src/matrix'; +import { LocationAssetType } from 'matrix-js-sdk/src/@types/location'; + +import { OwnBeaconStore, OwnBeaconStoreEvent } from '../../../stores/OwnBeaconStore'; +import { useEventEmitterState } from '../../../hooks/useEventEmitter'; +import { OwnProfileStore } from '../../../stores/OwnProfileStore'; +import OwnBeaconStatus from './OwnBeaconStatus'; +import { BeaconDisplayStatus } from './displayStatus'; +import MatrixClientContext from '../../../contexts/MatrixClientContext'; +import MemberAvatar from '../avatars/MemberAvatar'; +import StyledLiveBeaconIcon from './StyledLiveBeaconIcon'; + +interface Props { + roomId: Room['roomId']; +} + +const useOwnBeacon = (roomId: Room['roomId']): Beacon | undefined => { + const ownBeacon = useEventEmitterState( + OwnProfileStore.instance, + OwnBeaconStoreEvent.LivenessChange, + () => { + const [ownBeaconId] = OwnBeaconStore.instance.getLiveBeaconIds(roomId); + return OwnBeaconStore.instance.getBeaconById(ownBeaconId); + }, + ); + + return ownBeacon; +}; + +const DialogOwnBeaconStatus: React.FC = ({ roomId }) => { + const beacon = useOwnBeacon(roomId); + + const matrixClient = useContext(MatrixClientContext); + const room = matrixClient.getRoom(roomId); + + if (!beacon?.isLive) { + return null; + } + + const isSelfLocation = beacon.beaconInfo.assetType === LocationAssetType.Self; + const beaconMember = isSelfLocation ? + room.getMember(beacon.beaconInfoOwner) : + undefined; + + return
    + { isSelfLocation ? + : + + } + +
    ; +}; + +export default DialogOwnBeaconStatus; diff --git a/src/components/views/beacon/DialogSidebar.tsx b/src/components/views/beacon/DialogSidebar.tsx new file mode 100644 index 00000000000..4365b5fa8b6 --- /dev/null +++ b/src/components/views/beacon/DialogSidebar.tsx @@ -0,0 +1,50 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { Beacon } from 'matrix-js-sdk/src/matrix'; + +import { Icon as CloseIcon } from '../../../../res/img/image-view/close.svg'; +import { _t } from '../../../languageHandler'; +import AccessibleButton from '../elements/AccessibleButton'; +import Heading from '../typography/Heading'; +import BeaconListItem from './BeaconListItem'; + +interface Props { + beacons: Beacon[]; + requestClose: () => void; +} + +const DialogSidebar: React.FC = ({ beacons, requestClose }) => { + return
    +
    + { _t('View List') } + + + +
    +
      + { beacons.map((beacon) => ) } +
    +
    ; +}; + +export default DialogSidebar; diff --git a/src/components/views/beacon/LeftPanelLiveShareWarning.tsx b/src/components/views/beacon/LeftPanelLiveShareWarning.tsx index 07ba4cd2369..b93d8de1ae7 100644 --- a/src/components/views/beacon/LeftPanelLiveShareWarning.tsx +++ b/src/components/views/beacon/LeftPanelLiveShareWarning.tsx @@ -15,7 +15,8 @@ limitations under the License. */ import classNames from 'classnames'; -import React from 'react'; +import React, { useEffect } from 'react'; +import { Beacon, BeaconIdentifier, Room } from 'matrix-js-sdk/src/matrix'; import { useEventEmitterState } from '../../../hooks/useEventEmitter'; import { _t } from '../../../languageHandler'; @@ -34,10 +35,14 @@ interface Props { * Choose the most relevant beacon * and get its roomId */ -const chooseBestBeaconRoomId = (liveBeaconIds, errorBeaconIds): string | undefined => { +const chooseBestBeaconRoomId = ( + liveBeaconIds: BeaconIdentifier[], + updateErrorBeaconIds: BeaconIdentifier[], + locationErrorBeaconIds: BeaconIdentifier[], +): Room['roomId'] | undefined => { // both lists are ordered by creation timestamp in store // so select latest beacon - const beaconId = errorBeaconIds?.[0] ?? liveBeaconIds?.[0]; + const beaconId = updateErrorBeaconIds?.[0] ?? locationErrorBeaconIds?.[0] ?? liveBeaconIds?.[0]; if (!beaconId) { return undefined; } @@ -46,6 +51,35 @@ const chooseBestBeaconRoomId = (liveBeaconIds, errorBeaconIds): string | undefin return beacon?.roomId; }; +const getLabel = (hasStoppingErrors: boolean, hasLocationErrors: boolean): string => { + if (hasStoppingErrors) { + return _t('An error occurred while stopping your live location'); + } + if (hasLocationErrors) { + return _t('An error occured whilst sharing your live location'); + } + return _t('You are sharing your live location'); +}; + +const useLivenessMonitor = (liveBeaconIds: BeaconIdentifier[], beacons: Map): void => { + useEffect(() => { + // chromium sets the minimum timer interval to 1000ms + // for inactive tabs + // refresh beacon monitors when the tab becomes active again + const onPageVisibilityChanged = () => { + if (document.visibilityState === 'visible') { + liveBeaconIds.map(identifier => beacons.get(identifier)?.monitorLiveness()); + } + }; + if (liveBeaconIds.length) { + document.addEventListener("visibilitychange", onPageVisibilityChanged); + } + () => { + document.removeEventListener("visibilitychange", onPageVisibilityChanged); + }; + }, [liveBeaconIds, beacons]); +}; + const LeftPanelLiveShareWarning: React.FC = ({ isMinimized }) => { const isMonitoringLiveLocation = useEventEmitterState( OwnBeaconStore.instance, @@ -53,10 +87,18 @@ const LeftPanelLiveShareWarning: React.FC = ({ isMinimized }) => { () => OwnBeaconStore.instance.isMonitoringLiveLocation, ); - const beaconIdsWithWireError = useEventEmitterState( + const beaconIdsWithLocationPublishError = useEventEmitterState( OwnBeaconStore.instance, - OwnBeaconStoreEvent.WireError, - () => OwnBeaconStore.instance.getLiveBeaconIdsWithWireError(), + OwnBeaconStoreEvent.LocationPublishError, + () => OwnBeaconStore.instance.getLiveBeaconIdsWithLocationPublishError(), + ); + + const beaconIdsWithStoppingError = useEventEmitterState( + OwnBeaconStore.instance, + OwnBeaconStoreEvent.BeaconUpdateError, + () => OwnBeaconStore.instance.getLiveBeaconIds().filter( + beaconId => OwnBeaconStore.instance.beaconUpdateErrors.has(beaconId), + ), ); const liveBeaconIds = useEventEmitterState( @@ -65,13 +107,18 @@ const LeftPanelLiveShareWarning: React.FC = ({ isMinimized }) => { () => OwnBeaconStore.instance.getLiveBeaconIds(), ); - const hasWireErrors = !!beaconIdsWithWireError.length; + const hasLocationPublishErrors = !!beaconIdsWithLocationPublishError.length; + const hasStoppingErrors = !!beaconIdsWithStoppingError.length; + + useLivenessMonitor(liveBeaconIds, OwnBeaconStore.instance.beacons); if (!isMonitoringLiveLocation) { return null; } - const relevantBeaconRoomId = chooseBestBeaconRoomId(liveBeaconIds, beaconIdsWithWireError); + const relevantBeaconRoomId = chooseBestBeaconRoomId( + liveBeaconIds, beaconIdsWithStoppingError, beaconIdsWithLocationPublishError, + ); const onWarningClick = relevantBeaconRoomId ? () => { dispatcher.dispatch({ @@ -81,14 +128,12 @@ const LeftPanelLiveShareWarning: React.FC = ({ isMinimized }) => { }); } : undefined; - const label = hasWireErrors ? - _t('An error occured whilst sharing your live location') : - _t('You are sharing your live location'); + const label = getLabel(hasStoppingErrors, hasLocationPublishErrors); return > = ({ - beacon, displayStatus, className, ...rest + beacon, displayStatus, ...rest }) => { const { - hasWireError, + hasLocationPublishError, hasStopSharingError, stoppingInProgress, onStopSharing, - onResetWireError, + onResetLocationPublishError, } = useOwnLiveBeacons([beacon?.identifier]); // combine display status with errors that only occur for user's own beacons - const ownDisplayStatus = hasWireError || hasStopSharingError ? + const ownDisplayStatus = hasLocationPublishError || hasStopSharingError ? BeaconDisplayStatus.Error : displayStatus; return > = ({ { _t('Stop') } } - { hasWireError && { _t('Retry') } diff --git a/src/components/views/beacon/RoomLiveShareWarning.tsx b/src/components/views/beacon/RoomLiveShareWarning.tsx index 01c749a26ab..2fe76a10e88 100644 --- a/src/components/views/beacon/RoomLiveShareWarning.tsx +++ b/src/components/views/beacon/RoomLiveShareWarning.tsx @@ -15,7 +15,6 @@ limitations under the License. */ import React from 'react'; -import classNames from 'classnames'; import { Room } from 'matrix-js-sdk/src/matrix'; import { _t } from '../../../languageHandler'; @@ -28,8 +27,8 @@ import StyledLiveBeaconIcon from './StyledLiveBeaconIcon'; import { Icon as CloseIcon } from '../../../../res/img/image-view/close.svg'; import LiveTimeRemaining from './LiveTimeRemaining'; -const getLabel = (hasWireError: boolean, hasStopSharingError: boolean): string => { - if (hasWireError) { +const getLabel = (hasLocationPublishError: boolean, hasStopSharingError: boolean): string => { + if (hasLocationPublishError) { return _t('An error occured whilst sharing your live location, please try again'); } if (hasStopSharingError) { @@ -45,34 +44,34 @@ interface RoomLiveShareWarningInnerProps { const RoomLiveShareWarningInner: React.FC = ({ liveBeaconIds, roomId }) => { const { onStopSharing, - onResetWireError, + onResetLocationPublishError, beacon, stoppingInProgress, hasStopSharingError, - hasWireError, + hasLocationPublishError, } = useOwnLiveBeacons(liveBeaconIds); if (!beacon) { return null; } - const hasError = hasStopSharingError || hasWireError; + const hasError = hasStopSharingError || hasLocationPublishError; const onButtonClick = () => { - if (hasWireError) { - onResetWireError(); + if (hasLocationPublishError) { + onResetLocationPublishError(); } else { onStopSharing(); } }; return
    - { getLabel(hasWireError, hasStopSharingError) } + { getLabel(hasLocationPublishError, hasStopSharingError) } { stoppingInProgress && @@ -90,7 +89,7 @@ const RoomLiveShareWarningInner: React.FC = ({ l > { hasError ? _t('Retry') : _t('Stop sharing') } - { hasWireError && + +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, useState } from "react"; + +import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler"; +import IconizedContextMenu, { IconizedContextMenuOptionList, IconizedContextMenuRadio } from "./IconizedContextMenu"; +import { IProps as IContextMenuProps } from "../../structures/ContextMenu"; +import { _t, _td } from "../../../languageHandler"; + +const SECTION_NAMES: Record = { + [MediaDeviceKindEnum.AudioInput]: _td("Input devices"), + [MediaDeviceKindEnum.AudioOutput]: _td("Output devices"), + [MediaDeviceKindEnum.VideoInput]: _td("Cameras"), +}; + +interface IDeviceContextMenuDeviceProps { + label: string; + selected: boolean; + onClick: () => void; +} + +const DeviceContextMenuDevice: React.FC = ({ label, selected, onClick }) => { + return ; +}; + +interface IDeviceContextMenuSectionProps { + deviceKind: MediaDeviceKindEnum; +} + +const DeviceContextMenuSection: React.FC = ({ deviceKind }) => { + const [devices, setDevices] = useState([]); + const [selectedDevice, setSelectedDevice] = useState(MediaDeviceHandler.getDevice(deviceKind)); + + useEffect(() => { + const getDevices = async () => { + return setDevices((await MediaDeviceHandler.getDevices())[deviceKind]); + }; + getDevices(); + }, [deviceKind]); + + const onDeviceClick = (deviceId: string): void => { + MediaDeviceHandler.instance.setDevice(deviceId, deviceKind); + setSelectedDevice(deviceId); + }; + + return + { devices.map(({ label, deviceId }) => { + return onDeviceClick(deviceId)} + />; + }) } + ; +}; + +interface IProps extends IContextMenuProps { + deviceKinds: MediaDeviceKind[]; +} + +const DeviceContextMenu: React.FC = ({ deviceKinds, ...props }) => { + return + { deviceKinds.map((kind) => { + return ; + }) } + ; +}; + +export default DeviceContextMenu; diff --git a/src/components/views/context_menus/IconizedContextMenu.tsx b/src/components/views/context_menus/IconizedContextMenu.tsx index 2c6bdb3776b..9b7896790ef 100644 --- a/src/components/views/context_menus/IconizedContextMenu.tsx +++ b/src/components/views/context_menus/IconizedContextMenu.tsx @@ -33,6 +33,7 @@ interface IProps extends IContextMenuProps { interface IOptionListProps { first?: boolean; red?: boolean; + label?: string; className?: string; } @@ -126,13 +127,20 @@ export const IconizedContextMenuOption: React.FC = ({ ; }; -export const IconizedContextMenuOptionList: React.FC = ({ first, red, className, children }) => { +export const IconizedContextMenuOptionList: React.FC = ({ + first, + red, + className, + label, + children, +}) => { const classes = classNames("mx_IconizedContextMenu_optionList", className, { mx_IconizedContextMenu_optionList_notFirst: !first, mx_IconizedContextMenu_optionList_red: red, }); return
    + { label &&
    { label }
    } { children }
    ; }; diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 75cdebc9535..cf61ee5bfdd 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -1,6 +1,7 @@ /* Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2015 - 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 2022 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,12 +16,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactElement } from 'react'; +import React, { createRef } from 'react'; import { EventStatus, MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { EventType, RelationType } from "matrix-js-sdk/src/@types/event"; import { Relations } from 'matrix-js-sdk/src/models/relations'; import { RoomMemberEvent } from "matrix-js-sdk/src/models/room-member"; -import { M_LOCATION } from 'matrix-js-sdk/src/@types/location'; import { M_POLL_START } from "matrix-events-sdk"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; @@ -30,77 +30,80 @@ import Modal from '../../../Modal'; import Resend from '../../../Resend'; import SettingsStore from '../../../settings/SettingsStore'; import { isUrlPermitted } from '../../../HtmlUtils'; -import { isContentActionable } from '../../../utils/EventUtils'; +import { canEditContent, canForward, editEvent, isContentActionable, isLocationEvent } from '../../../utils/EventUtils'; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from './IconizedContextMenu'; import { ReadPinsEventId } from "../right_panel/types"; import { Action } from "../../../dispatcher/actions"; +import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks'; +import { ButtonEvent } from '../elements/AccessibleButton'; +import { copyPlaintext, getSelectedText } from '../../../utils/strings'; +import ContextMenu, { toRightOf, IPosition, ChevronFace } from '../../structures/ContextMenu'; +import ReactionPicker from '../emojipicker/ReactionPicker'; import ViewSource from '../../structures/ViewSource'; import { createRedactEventDialog } from '../dialogs/ConfirmRedactDialog'; import ShareDialog from '../dialogs/ShareDialog'; -import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; -import { ChevronFace, IPosition } from '../../structures/ContextMenu'; import RoomContext, { TimelineRenderingType } from '../../../contexts/RoomContext'; import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import EndPollDialog from '../dialogs/EndPollDialog'; import { isPollEnded } from '../messages/MPollBody'; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { GetRelationsForEvent, IEventTileOps } from "../rooms/EventTile"; import { OpenForwardDialogPayload } from "../../../dispatcher/payloads/OpenForwardDialogPayload"; import { OpenReportEventDialogPayload } from "../../../dispatcher/payloads/OpenReportEventDialogPayload"; import { createMapSiteLink } from '../../../utils/location'; -export function canCancel(status: EventStatus): boolean { - return status === EventStatus.QUEUED || status === EventStatus.NOT_SENT || status === EventStatus.ENCRYPTING; -} - -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 */ + // An optional EventTileOps implementation that can be used to unhide preview widgets eventTileOps?: IEventTileOps; + // Callback called when the menu is dismissed permalinkCreator?: RoomPermalinkCreator; /* an optional function to be called when the user clicks collapse thread, if not provided hide button */ collapseReplyChain?(): 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) */ + // If the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) onCloseDialog?(): void; - getRelationsForEvent?: ( - eventId: string, - relationType: string, - eventType: string - ) => Relations; + // True if the menu is being used as a right click menu + rightClick?: boolean; + // The Relations model from the JS SDK for reactions to `mxEvent` + reactions?: Relations; + // A permalink to the event + showPermalink?: boolean; + + getRelationsForEvent?: GetRelationsForEvent; } interface IState { canRedact: boolean; canPin: boolean; + reactionPickerDisplayed: boolean; } export default class MessageContextMenu extends React.Component { static contextType = RoomContext; public context!: React.ContextType; - state = { - canRedact: false, - canPin: false, - }; + private reactButtonRef = createRef(); // XXX Ref to a functional component + + constructor(props: IProps) { + super(props); + + this.state = { + canRedact: false, + canPin: false, + reactionPickerDisplayed: false, + }; + } - componentDidMount() { + public componentDidMount() { MatrixClientPeg.get().on(RoomMemberEvent.PowerLevel, this.checkPermissions); this.checkPermissions(); } - componentWillUnmount() { + public componentWillUnmount(): void { const cli = MatrixClientPeg.get(); if (cli) { cli.removeListener(RoomMemberEvent.PowerLevel, this.checkPermissions); @@ -233,11 +236,45 @@ export default class MessageContextMenu extends React.Component this.closeMenu(); }; + private onCopyPermalinkClick = (e: ButtonEvent): void => { + e.preventDefault(); // So that we don't open the permalink + copyPlaintext(this.getPermalink()); + this.closeMenu(); + }; + private onCollapseReplyChainClick = (): void => { this.props.collapseReplyChain(); this.closeMenu(); }; + private onCopyClick = (): void => { + copyPlaintext(getSelectedText()); + this.closeMenu(); + }; + + private onEditClick = (): void => { + editEvent(this.props.mxEvent, this.context.timelineRenderingType, this.props.getRelationsForEvent); + this.closeMenu(); + }; + + private onReplyClick = (): void => { + dis.dispatch({ + action: 'reply_to_event', + event: this.props.mxEvent, + context: this.context.timelineRenderingType, + }); + this.closeMenu(); + }; + + private onReactClick = (): void => { + this.setState({ reactionPickerDisplayed: true }); + }; + + private onCloseReactionPicker = (): void => { + this.setState({ reactionPickerDisplayed: false }); + this.closeMenu(); + }; + private onEndPollClick = (): void => { const matrixClient = MatrixClientPeg.get(); Modal.createTrackedDialog('End Poll', '', EndPollDialog, { @@ -258,11 +295,16 @@ export default class MessageContextMenu extends React.Component }); } + private getPermalink(): string { + if (!this.props.permalinkCreator) return; + return this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()); + } + private getUnsentReactions(): MatrixEvent[] { return this.getReactions(e => e.status === EventStatus.NOT_SENT); } - private viewInRoom = () => { + private viewInRoom = (): void => { dis.dispatch({ action: Action.ViewRoom, event_id: this.props.mxEvent.getId(), @@ -273,39 +315,35 @@ export default class MessageContextMenu extends React.Component this.closeMenu(); }; - render() { + public render(): JSX.Element { const cli = MatrixClientPeg.get(); const me = cli.getUserId(); - const mxEvent = this.props.mxEvent; + const { mxEvent, rightClick, showPermalink, eventTileOps, reactions, collapseReplyChain } = this.props; const eventStatus = mxEvent.status; const unsentReactionsCount = this.getUnsentReactions().length; - - let openInMapSiteButton: JSX.Element; - let endPollButton: JSX.Element; - 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 collapseReplyChain: JSX.Element; - let redactItemList: JSX.Element; - + const contentActionable = isContentActionable(mxEvent); + const permalink = this.getPermalink(); // status is SENT before remote-echo, null after const isSent = !eventStatus || eventStatus === EventStatus.SENT; - if (!mxEvent.isRedacted()) { - if (unsentReactionsCount !== 0) { - resendReactionsButton = ( - - ); - } + const { timelineRenderingType, canReact, canSendMessages } = this.context; + const isThread = ( + timelineRenderingType === TimelineRenderingType.Thread || + timelineRenderingType === TimelineRenderingType.ThreadsList + ); + const isThreadRootEvent = isThread && mxEvent?.getThread()?.rootEvent === mxEvent; + + let resendReactionsButton: JSX.Element; + if (!mxEvent.isRedacted() && unsentReactionsCount !== 0) { + resendReactionsButton = ( + + ); } + let redactButton: JSX.Element; if (isSent && this.state.canRedact) { redactButton = ( ); } + let openInMapSiteButton: JSX.Element; if (this.canOpenInMapSite(mxEvent)) { const mapSiteLink = createMapSiteLink(mxEvent); openInMapSiteButton = ( @@ -335,73 +374,73 @@ export default class MessageContextMenu extends React.Component ); } - if (isContentActionable(mxEvent)) { - if (canForward(mxEvent)) { - forwardButton = ( - - ); - } - - if (this.state.canPin) { - pinButton = ( - - ); - } - } - - let viewSourceButton: JSX.Element; - if (SettingsStore.getValue("developerMode")) { - viewSourceButton = ( + let forwardButton: JSX.Element; + if (contentActionable && canForward(mxEvent)) { + forwardButton = ( ); } - if (this.props.eventTileOps) { - if (this.props.eventTileOps.isWidgetHidden()) { - unhidePreviewButton = ( - - ); - } + let pinButton: JSX.Element; + if (contentActionable && this.state.canPin) { + pinButton = ( + + ); } - let permalink: string | null = null; - let permalinkButton: ReactElement | null = null; - if (this.props.permalinkCreator) { - permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()); - } - permalinkButton = ( + // This is specifically not behind the developerMode flag to give people insight into the Matrix + const viewSourceButton = ( ); + let unhidePreviewButton: JSX.Element; + if (eventTileOps?.isWidgetHidden()) { + unhidePreviewButton = ( + + ); + } + + let permalinkButton: JSX.Element; + if (permalink) { + permalinkButton = ( + + ); + } + + let endPollButton: JSX.Element; if (this.canEndPoll(mxEvent)) { endPollButton = ( ); } - if (this.props.eventTileOps) { // this event is rendered using TextualBody + let quoteButton: JSX.Element; + if (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" && + let externalURLButton: JSX.Element; + if ( + typeof (mxEvent.getContent().external_url) === "string" && isUrlPermitted(mxEvent.getContent().external_url) ) { externalURLButton = ( @@ -444,8 +486,9 @@ export default class MessageContextMenu extends React.Component ); } - if (this.props.collapseReplyChain) { - collapseReplyChain = ( + let collapseReplyChainButton: JSX.Element; + if (collapseReplyChain) { + collapseReplyChainButton = ( ); } - const { timelineRenderingType } = this.context; - const isThread = ( - timelineRenderingType === TimelineRenderingType.Thread || - timelineRenderingType === TimelineRenderingType.ThreadsList - ); - const isThreadRootEvent = isThread && this.props.mxEvent.isThreadRoot; + let copyButton: JSX.Element; + if (rightClick && getSelectedText()) { + copyButton = ( + + ); + } - const commonItemsList = ( - - { isThreadRootEvent && + ); + } + + let replyButton: JSX.Element; + if (rightClick && contentActionable && canSendMessages) { + replyButton = ( + + ); + } + + let reactButton; + if (rightClick && contentActionable && canReact) { + reactButton = ( + + ); + } + + let viewInRoomButton: JSX.Element; + if (isThreadRootEvent) { + viewInRoomButton = ( + } + /> + ); + } + + let nativeItemsList: JSX.Element; + if (copyButton) { + nativeItemsList = ( + + { copyButton } + + ); + } + + let quickItemsList: JSX.Element; + if (editButton || replyButton || reactButton) { + quickItemsList = ( + + { reactButton } + { replyButton } + { editButton } + + ); + } + + const commonItemsList = ( + + { viewInRoomButton } { openInMapSiteButton } { endPollButton } { quoteButton } @@ -490,10 +599,11 @@ export default class MessageContextMenu extends React.Component { unhidePreviewButton } { viewSourceButton } { resendReactionsButton } - { collapseReplyChain } + { collapseReplyChainButton } ); + let redactItemList: JSX.Element; if (redactButton) { redactItemList = ( @@ -501,33 +611,40 @@ export default class MessageContextMenu extends React.Component ); } + + let reactionPicker: JSX.Element; + if (this.state.reactionPickerDisplayed) { + const buttonRect = (this.reactButtonRef.current as HTMLElement)?.getBoundingClientRect(); + reactionPicker = ( + + + + ); + } + return ( - - { commonItemsList } - { redactItemList } - + + + { nativeItemsList } + { quickItemsList } + { commonItemsList } + { redactItemList } + + { reactionPicker } + ); } } -function canForward(event: MatrixEvent): boolean { - return !( - isLocationEvent(event) || - M_POLL_START.matches(event.getType()) - ); -} - -function isLocationEvent(event: MatrixEvent): boolean { - const eventType = event.getType(); - return ( - M_LOCATION.matches(eventType) || - ( - eventType === EventType.RoomMessage && - M_LOCATION.matches(event.getContent().msgtype) - ) - ); -} diff --git a/src/components/views/context_menus/SpaceContextMenu.tsx b/src/components/views/context_menus/SpaceContextMenu.tsx index d9286c618b4..5d045900453 100644 --- a/src/components/views/context_menus/SpaceContextMenu.tsx +++ b/src/components/views/context_menus/SpaceContextMenu.tsx @@ -16,7 +16,7 @@ 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 { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; import { IProps as IContextMenuProps } from "../../structures/ContextMenu"; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu"; @@ -136,6 +136,7 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) = const hasPermissionToAddSpaceChild = space.currentState.maySendStateEvent(EventType.SpaceChild, userId); const canAddRooms = hasPermissionToAddSpaceChild && shouldShowComponent(UIComponent.CreateRooms); + const canAddVideoRooms = canAddRooms && SettingsStore.getValue("feature_video_rooms"); const canAddSubSpaces = hasPermissionToAddSpaceChild && shouldShowComponent(UIComponent.CreateSpaces); let newRoomSection: JSX.Element; @@ -149,6 +150,14 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) = onFinished(); }; + const onNewVideoRoomClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showCreateNewRoom(space, RoomType.ElementVideo); + onFinished(); + }; + const onNewSubspaceClick = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); @@ -169,6 +178,14 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) = onClick={onNewRoomClick} /> } + { canAddVideoRooms && + + } { canAddSubSpaces && { // align the context menu's icons with the icon which opened the context menu - const left = elementRect.left + window.pageXOffset + elementRect.width; - const top = elementRect.bottom + window.pageYOffset; + const left = elementRect.left + window.scrollX + elementRect.width; + const top = elementRect.bottom + window.scrollY; const chevronFace = ChevronFace.None; return { left, top, chevronFace }; }; diff --git a/src/components/views/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx index 1aeeccc724b..b6a46bd2aca 100644 --- a/src/components/views/context_menus/WidgetContextMenu.tsx +++ b/src/components/views/context_menus/WidgetContextMenu.tsx @@ -110,7 +110,8 @@ const WidgetContextMenu: React.FC = ({ } let snapshotButton; - if (widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots)) { + const screenshotsEnabled = SettingsStore.getValue("enableWidgetScreenshots"); + if (screenshotsEnabled && widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots)) { const onSnapshotClick = () => { widgetMessaging?.takeScreenshot().then(data => { dis.dispatch({ diff --git a/src/components/views/dialogs/DeactivateAccountDialog.tsx b/src/components/views/dialogs/DeactivateAccountDialog.tsx index a505d5e647d..dbdc3b3639a 100644 --- a/src/components/views/dialogs/DeactivateAccountDialog.tsx +++ b/src/components/views/dialogs/DeactivateAccountDialog.tsx @@ -86,7 +86,7 @@ export default class DeactivateAccountDialog extends React.Component
    -

    { _t( - "This will make your account permanently unusable. " + - "You will not be able to log in, and no one will be able to re-register the same " + - "user ID. " + - "This will cause your account to leave all rooms it is participating in, and it " + - "will remove your account details from your identity server. " + - "This action is irreversible.", - {}, - { b: (sub) => { sub } }, - ) }

    - -

    { _t( - "Deactivating your account does not by default cause us to forget messages you " + - "have sent. " + - "If you would like us to forget your messages, please tick the box below.", - {}, - { b: (sub) => { sub } }, - ) }

    - -

    { _t( - "Message visibility in Matrix is similar to email. " + - "Our forgetting your messages means that messages you have sent will not be shared " + - "with any new or unregistered users, but registered users who already have access " + - "to these messages will still have access to their copy.", - ) }

    +

    { _t("Confirm that you would like to deactivate your account. If you proceed:") }

    +
      +
    • { _t("You will not be able to reactivate your account") }
    • +
    • { _t("You will no longer be able to log in") }
    • +
    • { _t("No one will be able to reuse your username (MXID), including you: this username will remain unavailable") }
    • +
    • { _t("You will leave all rooms and DMs that you are in") }
    • +
    • { _t("You will be removed from the identity server: your friends will no longer be able to find you with your email or phone number") }
    • +
    +

    { _t("Your old messages will still be visible to people who received them, just like emails you sent in the past. Would you like to hide your sent messages from people who join rooms in the future?") }

    @@ -238,20 +222,12 @@ export default class DeactivateAccountDialog extends React.Component - { _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 } }, - ) } + { _t("Hide my messages from new joiners") }

    - { error } { auth }
    -
    ); diff --git a/src/components/views/dialogs/DevtoolsDialog.tsx b/src/components/views/dialogs/DevtoolsDialog.tsx index 14fb69b1563..fb2f7113dd8 100644 --- a/src/components/views/dialogs/DevtoolsDialog.tsx +++ b/src/components/views/dialogs/DevtoolsDialog.tsx @@ -101,6 +101,7 @@ const DevtoolsDialog: React.FC = ({ roomId, onFinished }) => {

    { _t("Options") }

    +
    ; } diff --git a/src/components/views/dialogs/ExportDialog.tsx b/src/components/views/dialogs/ExportDialog.tsx index 6881c1c52d8..5c1bf48cac2 100644 --- a/src/components/views/dialogs/ExportDialog.tsx +++ b/src/components/views/dialogs/ExportDialog.tsx @@ -200,7 +200,7 @@ const ExportDialog: React.FC = ({ room, onFinished }) => { }, { key: "number", test: ({ value }) => { - const parsedSize = parseInt(value as string, 10); + const parsedSize = parseInt(value, 10); return validateNumberInRange(1, 2000)(parsedSize); }, invalid: () => { @@ -238,7 +238,7 @@ const ExportDialog: React.FC = ({ room, onFinished }) => { }, { key: "number", test: ({ value }) => { - const parsedSize = parseInt(value as string, 10); + const parsedSize = parseInt(value, 10); return validateNumberInRange(1, 10 ** 8)(parsedSize); }, invalid: () => { diff --git a/src/components/views/dialogs/ForwardDialog.tsx b/src/components/views/dialogs/ForwardDialog.tsx index 0a063df9c7b..100b04bc5ac 100644 --- a/src/components/views/dialogs/ForwardDialog.tsx +++ b/src/components/views/dialogs/ForwardDialog.tsx @@ -131,7 +131,7 @@ const Entry: React.FC = ({ room, type, content, matrixClient: cli, @@ -261,6 +261,7 @@ const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCr { rooms.length > 0 ? (
    rooms.slice(start, end).map(room => diff --git a/src/components/views/dialogs/MessageEditHistoryDialog.tsx b/src/components/views/dialogs/MessageEditHistoryDialog.tsx index 2dedfb52937..0f51530a128 100644 --- a/src/components/views/dialogs/MessageEditHistoryDialog.tsx +++ b/src/components/views/dialogs/MessageEditHistoryDialog.tsx @@ -138,7 +138,8 @@ export default class MessageEditHistoryDialog extends React.PureComponent)); + /> + )); lastEvent = e; }); return nodes; diff --git a/src/components/views/dialogs/SpotlightDialog.tsx b/src/components/views/dialogs/SpotlightDialog.tsx index f579e262875..f5efc0b8dc8 100644 --- a/src/components/views/dialogs/SpotlightDialog.tsx +++ b/src/components/views/dialogs/SpotlightDialog.tsx @@ -74,6 +74,7 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { PosthogAnalytics } from "../../../PosthogAnalytics"; import { getCachedRoomIDForAlias } from "../../../RoomAliasCache"; import { roomContextDetailsText, spaceContextDetailsText } from "../../../utils/i18n-helpers"; +import { RecentAlgorithm } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; const MAX_RECENT_SEARCHES = 10; const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons @@ -210,6 +211,8 @@ type Result = IRoomResult | IResult; const isRoomResult = (result: any): result is IRoomResult => !!result?.room; +const recentAlgorithm = new RecentAlgorithm(); + export const useWebSearchMetrics = (numResults: number, queryLength: number, viaSpotlight: boolean): void => { useEffect(() => { if (!queryLength) return; @@ -280,6 +283,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", onFinished }) => const results: [Result[], Result[], Result[]] = [[], [], []]; + // Group results in their respective sections possibleResults.forEach(entry => { if (isRoomResult(entry)) { if (!entry.room.normalizedName.includes(normalizedQuery) && @@ -295,8 +299,25 @@ const SpotlightDialog: React.FC = ({ initialText = "", onFinished }) => results[entry.section].push(entry); }); + // Sort results by most recent activity + + const myUserId = cli.getUserId(); + for (const resultArray of results) { + resultArray.sort((a: Result, b: Result) => { + // This is not a room result, it should appear at the bottom of + // the list + if (!(a as IRoomResult).room) return 1; + if (!(b as IRoomResult).room) return -1; + + const roomA = (a as IRoomResult).room; + const roomB = (b as IRoomResult).room; + + return recentAlgorithm.getLastTs(roomB, myUserId) - recentAlgorithm.getLastTs(roomA, myUserId); + }); + } + return results; - }, [possibleResults, trimmedQuery]); + }, [possibleResults, trimmedQuery, cli]); const numResults = trimmedQuery ? people.length + rooms.length + spaces.length : 0; useWebSearchMetrics(numResults, query.length, true); @@ -367,16 +388,16 @@ const SpotlightDialog: React.FC = ({ initialText = "", onFinished }) => ); } - const otherResult = (result as IResult); + // IResult case return ( ); }; diff --git a/src/components/views/dialogs/TabbedIntegrationManagerDialog.tsx b/src/components/views/dialogs/TabbedIntegrationManagerDialog.tsx deleted file mode 100644 index 5a5d6e38229..00000000000 --- a/src/components/views/dialogs/TabbedIntegrationManagerDialog.tsx +++ /dev/null @@ -1,176 +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. -*/ - -import React from 'react'; -import { Room } from "matrix-js-sdk/src/models/room"; -import classNames from 'classnames'; -import { logger } from "matrix-js-sdk/src/logger"; - -import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; -import { dialogTermsInteractionCallback, TermsNotSignedError } from "../../../Terms"; -import * as ScalarMessaging from "../../../ScalarMessaging"; -import { IntegrationManagerInstance } from "../../../integrations/IntegrationManagerInstance"; -import ScalarAuthClient from "../../../ScalarAuthClient"; -import AccessibleButton from "../elements/AccessibleButton"; -import IntegrationManager from "../settings/IntegrationManager"; -import { IDialogProps } from "./IDialogProps"; - -interface IProps extends IDialogProps { - /** - * Optional room where the integration manager should be open to - */ - room?: Room; - - /** - * Optional screen to open on the integration manager - */ - screen?: string; - - /** - * Optional integration ID to open in the integration manager - */ - integrationId?: string; -} - -interface IState { - managers: IntegrationManagerInstance[]; - busy: boolean; - currentIndex: number; - currentConnected: boolean; - currentLoading: boolean; - currentScalarClient: ScalarAuthClient; -} - -export default class TabbedIntegrationManagerDialog extends React.Component { - constructor(props: IProps) { - super(props); - - this.state = { - managers: IntegrationManagers.sharedInstance().getOrderedManagers(), - busy: true, - currentIndex: 0, - currentConnected: false, - currentLoading: true, - currentScalarClient: null, - }; - } - - public componentDidMount(): void { - this.openManager(0, true); - } - - private openManager = async (i: number, force = false): Promise => { - if (i === this.state.currentIndex && !force) return; - - const manager = this.state.managers[i]; - const client = manager.getScalarClient(); - this.setState({ - busy: true, - currentIndex: i, - currentLoading: true, - currentConnected: false, - currentScalarClient: client, - }); - - ScalarMessaging.setOpenManagerUrl(manager.uiUrl); - - client.setTermsInteractionCallback((policyInfo, agreedUrls) => { - // To avoid visual glitching of two modals stacking briefly, we customise the - // terms dialog sizing when it will appear for the integration manager so that - // it gets the same basic size as the IM's own modal. - return dialogTermsInteractionCallback( - policyInfo, agreedUrls, 'mx_TermsDialog_forIntegrationManager', - ); - }); - - try { - await client.connect(); - if (!client.hasCredentials()) { - this.setState({ - busy: false, - currentLoading: false, - currentConnected: false, - }); - } else { - this.setState({ - busy: false, - currentLoading: false, - currentConnected: true, - }); - } - } catch (e) { - if (e instanceof TermsNotSignedError) { - return; - } - - logger.error(e); - this.setState({ - busy: false, - currentLoading: false, - currentConnected: false, - }); - } - }; - - private renderTabs(): JSX.Element[] { - return this.state.managers.map((m, i) => { - const classes = classNames({ - 'mx_TabbedIntegrationManagerDialog_tab': true, - 'mx_TabbedIntegrationManagerDialog_currentTab': this.state.currentIndex === i, - }); - return ( - this.openManager(i)} - key={`tab_${i}`} - disabled={this.state.busy} - > - { m.name } - - ); - }); - } - - public renderTab(): JSX.Element { - let uiUrl = null; - if (this.state.currentScalarClient) { - uiUrl = this.state.currentScalarClient.getScalarInterfaceUrlForRoom( - this.props.room, - this.props.screen, - this.props.integrationId, - ); - } - return {/* no-op */}} - />; - } - - public render(): JSX.Element { - return ( -
    -
    - { this.renderTabs() } -
    -
    - { this.renderTab() } -
    -
    - ); - } -} diff --git a/src/components/views/dialogs/devtools/ServerInfo.tsx b/src/components/views/dialogs/devtools/ServerInfo.tsx index ff18d836e5c..23b6528eacc 100644 --- a/src/components/views/dialogs/devtools/ServerInfo.tsx +++ b/src/components/views/dialogs/devtools/ServerInfo.tsx @@ -74,13 +74,13 @@ const ServerInfo = ({ onBack }: IDevtoolsProps) => { }

    { _t("Client Versions") }

    - { capabilities !== FAILED_TO_LOAD + { clientVersions !== FAILED_TO_LOAD ? :
    { _t("Failed to load.") }
    }

    { _t("Server Versions") }

    - { capabilities !== FAILED_TO_LOAD + { serverVersions !== FAILED_TO_LOAD ? :
    { _t("Failed to load.") }
    } diff --git a/src/components/views/dialogs/security/CreateCrossSigningDialog.tsx b/src/components/views/dialogs/security/CreateCrossSigningDialog.tsx index ca5b1db9fbc..4b1928c3a73 100644 --- a/src/components/views/dialogs/security/CreateCrossSigningDialog.tsx +++ b/src/components/views/dialogs/security/CreateCrossSigningDialog.tsx @@ -93,7 +93,7 @@ export default class CreateCrossSigningDialog extends React.PureComponent void): Promise => { if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) { - await makeRequest({ + makeRequest({ type: 'm.login.password', identifier: { type: 'm.id.user', @@ -106,7 +106,7 @@ export default class CreateCrossSigningDialog extends React.PureComponent) => ({ @@ -85,8 +81,6 @@ const validServer = withValidation({ ], }); -export type Protocols = Record; - interface IProps { protocols: Protocols; selectedServerName: string; diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx index bc96bfe7084..3db6d0dfb05 100644 --- a/src/components/views/elements/AccessibleButton.tsx +++ b/src/components/views/elements/AccessibleButton.tsx @@ -53,6 +53,7 @@ interface IProps extends React.InputHTMLAttributes { tabIndex?: number; disabled?: boolean; className?: string; + triggerOnMouseDown?: boolean; onClick(e?: ButtonEvent): void | Promise; } @@ -78,13 +79,18 @@ export default function AccessibleButton({ className, onKeyDown, onKeyUp, + triggerOnMouseDown, ...restProps }: IProps) { const newProps: IAccessibleButtonProps = restProps; if (disabled) { newProps["aria-disabled"] = true; } else { - newProps.onClick = onClick; + if (triggerOnMouseDown) { + newProps.onMouseDown = onClick; + } else { + newProps.onClick = onClick; + } // We need to consume enter onKeyDown and space onKeyUp // otherwise we are risking also activating other keyboard focusable elements // that might receive focus as a result of the AccessibleButtonClick action diff --git a/src/components/views/elements/AccessibleTooltipButton.tsx b/src/components/views/elements/AccessibleTooltipButton.tsx index d7a001460ea..1e0abe1fe9b 100644 --- a/src/components/views/elements/AccessibleTooltipButton.tsx +++ b/src/components/views/elements/AccessibleTooltipButton.tsx @@ -23,11 +23,12 @@ import Tooltip, { Alignment } from './Tooltip'; interface IProps extends React.ComponentProps { title: string; tooltip?: React.ReactNode; - label?: React.ReactNode; + label?: string; tooltipClassName?: string; forceHide?: boolean; yOffset?: number; alignment?: Alignment; + onHover?: (hovering: boolean) => void; onHideTooltip?(ev: SyntheticEvent): void; } @@ -52,6 +53,7 @@ export default class AccessibleTooltipButton extends React.PureComponent { + if (this.props.onHover) this.props.onHover(true); if (this.props.forceHide) return; this.setState({ hover: true, @@ -59,6 +61,7 @@ export default class AccessibleTooltipButton extends React.PureComponent { + if (this.props.onHover) this.props.onHover(false); this.setState({ hover: false, }); diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index e9e8c9474a5..1eba26a3d4e 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -117,30 +117,6 @@ export default class AppTile extends React.Component { showLayoutButtons: true, }; - // We track a count of all "live" `AppTile`s for a given widget UID. - // For this purpose, an `AppTile` is considered live from the time it is - // constructed until it is unmounted. This is used to aid logic around when - // to tear down the widget iframe. See `componentWillUnmount` for details. - private static liveTilesByUid = new Map(); - - public static addLiveTile(widgetId: string, roomId: string): void { - const uid = WidgetUtils.calcWidgetUid(widgetId, roomId); - const refs = this.liveTilesByUid.get(uid) ?? 0; - this.liveTilesByUid.set(uid, refs + 1); - } - - public static removeLiveTile(widgetId: string, roomId: string): void { - const uid = WidgetUtils.calcWidgetUid(widgetId, roomId); - const refs = this.liveTilesByUid.get(uid); - if (refs) this.liveTilesByUid.set(uid, refs - 1); - } - - public static isLive(widgetId: string, roomId: string): boolean { - const uid = WidgetUtils.calcWidgetUid(widgetId, roomId); - const refs = this.liveTilesByUid.get(uid) ?? 0; - return refs > 0; - } - private contextMenuButton = createRef(); private iframe: HTMLIFrameElement; // ref to the iframe (callback style) private allowedWidgetsWatchRef: string; @@ -152,7 +128,10 @@ export default class AppTile extends React.Component { constructor(props: IProps) { super(props); - AppTile.addLiveTile(this.props.app.id, this.props.app.roomId); + // Tiles in miniMode are floating, and therefore not docked + if (!this.props.miniMode) { + ActiveWidgetStore.instance.dockWidget(this.props.app.id, this.props.app.roomId); + } // The key used for PersistedElement this.persistKey = getPersistKey(WidgetUtils.getWidgetUid(this.props.app)); @@ -284,27 +263,14 @@ export default class AppTile extends React.Component { public componentWillUnmount(): void { this.unmounted = true; - // It might seem simplest to always tear down the widget itself here, - // and indeed that would be a bit easier to reason about... however, we - // support moving widgets between containers (e.g. top <-> center). - // During such a move, this component will unmount from the old - // container and remount in the new container. By keeping the widget - // iframe loaded across this transition, the widget doesn't notice that - // anything happened, which improves overall widget UX. During this kind - // of movement between containers, the new `AppTile` for the new - // container is constructed before the old one unmounts. By counting the - // mounted `AppTile`s for each widget, we know to only tear down the - // widget iframe when the last the `AppTile` unmounts. - AppTile.removeLiveTile(this.props.app.id, this.props.app.roomId); - - // We also support a separate "persistence" mode where a single widget - // can request to be "sticky" and follow you across rooms in a PIP - // container. - const isActiveWidget = ActiveWidgetStore.instance.getWidgetPersistence( - this.props.app.id, this.props.app.roomId, - ); + if (!this.props.miniMode) { + ActiveWidgetStore.instance.undockWidget(this.props.app.id, this.props.app.roomId); + } - if (!AppTile.isLive(this.props.app.id, this.props.app.roomId) && !isActiveWidget) { + // Only tear down the widget if no other component is keeping it alive, + // because we support moving widgets between containers, in which case + // another component will keep it loaded throughout the transition + if (!ActiveWidgetStore.instance.isLive(this.props.app.id, this.props.app.roomId)) { this.endWidgetActions(); } diff --git a/src/components/views/elements/AppWarning.tsx b/src/components/views/elements/AppWarning.tsx index 352c5990680..b3dfae99118 100644 --- a/src/components/views/elements/AppWarning.tsx +++ b/src/components/views/elements/AppWarning.tsx @@ -1,3 +1,19 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + import React from 'react'; interface IProps { diff --git a/src/components/views/elements/CopyableText.tsx b/src/components/views/elements/CopyableText.tsx index d1632af3825..f95cbcbd168 100644 --- a/src/components/views/elements/CopyableText.tsx +++ b/src/components/views/elements/CopyableText.tsx @@ -16,6 +16,7 @@ limitations under the License. */ import React, { useState } from "react"; +import classNames from "classnames"; import { _t } from "../../../languageHandler"; import { copyPlaintext } from "../../../utils/strings"; @@ -23,11 +24,12 @@ import { ButtonEvent } from "./AccessibleButton"; import AccessibleTooltipButton from "./AccessibleTooltipButton"; interface IProps { - children: React.ReactNode; + children?: React.ReactNode; getTextToCopy: () => string; + border?: boolean; } -const CopyableText: React.FC = ({ children, getTextToCopy }) => { +const CopyableText: React.FC = ({ children, getTextToCopy, border=true }) => { const [tooltip, setTooltip] = useState(undefined); const onCopyClickInternal = async (e: ButtonEvent) => { @@ -42,7 +44,11 @@ const CopyableText: React.FC = ({ children, getTextToCopy }) => { } }; - return
    + const className = classNames("mx_CopyableText", { + mx_CopyableText_border: border, + }); + + return
    { children } { - room: Room; - onlyKnownUsers?: boolean; - numShown?: number; + members: RoomMember[]; + faceSize: number; + overflow: boolean; + tooltip?: ReactNode; + children?: ReactNode; } -const isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsForUserId(member.userId)?.length; - -const FacePile: FC = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props }) => { - const cli = useContext(MatrixClientContext); - const isJoined = room.getMyMembership() === "join"; - let members = useRoomMembers(room); - const count = members.length; - - // sort users with an explicit avatar first - const iteratees = [member => member.getMxcAvatarUrl() ? 0 : 1]; - if (onlyKnownUsers) { - members = members.filter(isKnownMember); - } else { - // sort known users first - iteratees.unshift(member => isKnownMember(member) ? 0 : 1); - } - - // exclude ourselves from the shown members list - const shownMembers = sortBy(members.filter(m => m.userId !== cli.getUserId()), iteratees).slice(0, numShown); - if (shownMembers.length < 1) return null; - - // We reverse the order of the shown faces in CSS to simplify their visual overlap, - // reverse members in tooltip order to make the order between the two match up. - const commaSeparatedMembers = shownMembers.map(m => m.rawDisplayName).reverse().join(", "); - - let tooltip: ReactNode; - if (props.onClick) { - let subText: string; - if (isJoined) { - subText = _t("Including you, %(commaSeparatedMembers)s", { commaSeparatedMembers }); - } else { - subText = _t("Including %(commaSeparatedMembers)s", { commaSeparatedMembers }); - } - - tooltip =
    -
    - { _t("View all %(count)s members", { count }) } -
    -
    - { subText } -
    -
    ; - } else { - if (isJoined) { - tooltip = _t("%(count)s members including you, %(commaSeparatedMembers)s", { - count: count - 1, - commaSeparatedMembers, - }); - } else { - tooltip = _t("%(count)s members including %(commaSeparatedMembers)s", { - count, - commaSeparatedMembers, - }); - } - } +const FacePile: FC = ({ members, faceSize, overflow, tooltip, children, ...props }) => { + const faces = members.map( + tooltip + ? m => + : m => + + , + ); + + const pileContents = <> + { overflow ? : null } + { faces } + ; return
    - - { members.length > numShown ? : null } - { shownMembers.map(m => - ) } - - { onlyKnownUsers && - { _t("%(count)s people you know have already joined", { count: members.length }) } - } + { tooltip ? ( + + { pileContents } + + ) : ( +
    + { pileContents } +
    + ) } + { children }
    ; }; diff --git a/src/components/views/elements/IRCTimelineProfileResizer.tsx b/src/components/views/elements/IRCTimelineProfileResizer.tsx index c0e37d93d74..61fee35bdd7 100644 --- a/src/components/views/elements/IRCTimelineProfileResizer.tsx +++ b/src/components/views/elements/IRCTimelineProfileResizer.tsx @@ -44,7 +44,7 @@ export default class IRCTimelineProfileResizer extends React.Component this.updateCSSWidth(this.state.width)); } diff --git a/src/components/views/elements/InteractiveTooltip.tsx b/src/components/views/elements/InteractiveTooltip.tsx index 62d0c43d06a..ca8ae4c8fd5 100644 --- a/src/components/views/elements/InteractiveTooltip.tsx +++ b/src/components/views/elements/InteractiveTooltip.tsx @@ -352,10 +352,10 @@ export default class InteractiveTooltip extends React.Component const targetRect = this.target.getBoundingClientRect(); if (this.props.direction === Direction.Left) { - const targetLeft = targetRect.left + window.pageXOffset; + const targetLeft = targetRect.left + window.scrollX; return !contentRect || (targetLeft - contentRect.width > MIN_SAFE_DISTANCE_TO_WINDOW_EDGE); } else { - const targetRight = targetRect.right + window.pageXOffset; + const targetRight = targetRect.right + window.scrollX; const spaceOnRight = UIStore.instance.windowWidth - targetRight; return contentRect && (spaceOnRight - contentRect.width < MIN_SAFE_DISTANCE_TO_WINDOW_EDGE); } @@ -366,10 +366,10 @@ export default class InteractiveTooltip extends React.Component const targetRect = this.target.getBoundingClientRect(); if (this.props.direction === Direction.Top) { - const targetTop = targetRect.top + window.pageYOffset; + const targetTop = targetRect.top + window.scrollY; return !contentRect || (targetTop - contentRect.height > MIN_SAFE_DISTANCE_TO_WINDOW_EDGE); } else { - const targetBottom = targetRect.bottom + window.pageYOffset; + const targetBottom = targetRect.bottom + window.scrollY; const spaceBelow = UIStore.instance.windowHeight - targetBottom; return contentRect && (spaceBelow - contentRect.height < MIN_SAFE_DISTANCE_TO_WINDOW_EDGE); } @@ -429,10 +429,10 @@ export default class InteractiveTooltip extends React.Component const targetRect = this.target.getBoundingClientRect(); // The window X and Y offsets are to adjust position when zoomed in to page - const targetLeft = targetRect.left + window.pageXOffset; - const targetRight = targetRect.right + window.pageXOffset; - const targetBottom = targetRect.bottom + window.pageYOffset; - const targetTop = targetRect.top + window.pageYOffset; + const targetLeft = targetRect.left + window.scrollX; + const targetRight = targetRect.right + window.scrollX; + const targetBottom = targetRect.bottom + window.scrollY; + const targetTop = targetRect.top + window.scrollY; // Place the tooltip above the target by default. If we find that the // tooltip content would extend past the safe area towards the window diff --git a/src/components/views/elements/Pill.js b/src/components/views/elements/Pill.tsx similarity index 71% rename from src/components/views/elements/Pill.js rename to src/components/views/elements/Pill.tsx index 7d5a9973c7f..f344f894569 100644 --- a/src/components/views/elements/Pill.js +++ b/src/components/views/elements/Pill.tsx @@ -13,67 +13,82 @@ 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 classNames from 'classnames'; import { Room } from 'matrix-js-sdk/src/models/room'; import { RoomMember } from 'matrix-js-sdk/src/models/room-member'; -import PropTypes from 'prop-types'; import { logger } from "matrix-js-sdk/src/logger"; +import { MatrixClient } from 'matrix-js-sdk/src/client'; import dis from '../../../dispatcher/dispatcher'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { getPrimaryPermalinkEntity, parsePermalink } from "../../../utils/permalinks/Permalinks"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { Action } from "../../../dispatcher/actions"; -import Tooltip from './Tooltip'; -import RoomAvatar from "../avatars/RoomAvatar"; -import MemberAvatar from "../avatars/MemberAvatar"; +import Tooltip, { Alignment } from './Tooltip'; +import RoomAvatar from '../avatars/RoomAvatar'; +import MemberAvatar from '../avatars/MemberAvatar'; + +export enum PillType { + UserMention = 'TYPE_USER_MENTION', + RoomMention = 'TYPE_ROOM_MENTION', + AtRoomMention = 'TYPE_AT_ROOM_MENTION', // '@room' mention +} + +interface IProps { + // The Type of this Pill. If url is given, this is auto-detected. + type?: PillType; + // The URL to pillify (no validation is done) + url?: string; + // Whether the pill is in a message + inMessage?: boolean; + // The room in which this pill is being rendered + room?: Room; + // Whether to include an avatar in the pill + shouldShowPillAvatar?: boolean; +} + +interface IState { + // ID/alias of the room/user + resourceId: string; + // Type of pill + pillType: string; + // The member related to the user pill + member?: RoomMember; + // The room related to the room pill + room?: Room; + // Is the user hovering the pill + hover: boolean; +} -class Pill extends React.Component { - static roomNotifPos(text) { +export default class Pill extends React.Component { + private unmounted = true; + private matrixClient: MatrixClient; + + public static roomNotifPos(text: string): number { return text.indexOf("@room"); } - static roomNotifLen() { + public static roomNotifLen(): number { return "@room".length; } - static TYPE_USER_MENTION = 'TYPE_USER_MENTION'; - static TYPE_ROOM_MENTION = 'TYPE_ROOM_MENTION'; - static TYPE_AT_ROOM_MENTION = 'TYPE_AT_ROOM_MENTION'; // '@room' mention - - static propTypes = { - // The Type of this Pill. If url is given, this is auto-detected. - type: PropTypes.string, - // The URL to pillify (no validation is done) - url: PropTypes.string, - // Whether the pill is in a message - inMessage: PropTypes.bool, - // The room in which this pill is being rendered - room: PropTypes.instanceOf(Room), - // Whether to include an avatar in the pill - shouldShowPillAvatar: PropTypes.bool, - // Whether to render this pill as if it were highlit by a selection - isSelected: PropTypes.bool, - }; - - state = { - // ID/alias of the room/user - resourceId: null, - // Type of pill - pillType: null, + constructor(props: IProps) { + super(props); - // The member related to the user pill - member: null, - // The room related to the room pill - room: null, - // Is the user hovering the pill - hover: false, - }; + this.state = { + resourceId: null, + pillType: null, + member: null, + room: null, + hover: false, + }; + } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - // eslint-disable-next-line camelcase - async UNSAFE_componentWillReceiveProps(nextProps) { + // eslint-disable-next-line camelcase, @typescript-eslint/naming-convention + public async UNSAFE_componentWillReceiveProps(nextProps: IProps): Promise { let resourceId; let prefix; @@ -89,28 +104,28 @@ class Pill extends React.Component { } const pillType = this.props.type || { - '@': Pill.TYPE_USER_MENTION, - '#': Pill.TYPE_ROOM_MENTION, - '!': Pill.TYPE_ROOM_MENTION, + '@': PillType.UserMention, + '#': PillType.RoomMention, + '!': PillType.RoomMention, }[prefix]; let member; let room; switch (pillType) { - case Pill.TYPE_AT_ROOM_MENTION: { + case PillType.AtRoomMention: { room = nextProps.room; } break; - case Pill.TYPE_USER_MENTION: { + case PillType.UserMention: { const localMember = nextProps.room ? nextProps.room.getMember(resourceId) : undefined; member = localMember; if (!localMember) { member = new RoomMember(null, resourceId); this.doProfileLookup(resourceId, member); } - break; } - case Pill.TYPE_ROOM_MENTION: { + break; + case PillType.RoomMention: { const localRoom = resourceId[0] === '#' ? MatrixClientPeg.get().getRooms().find((r) => { return r.getCanonicalAlias() === resourceId || @@ -122,39 +137,39 @@ class Pill extends React.Component { // a room avatar and name. // this.doRoomProfileLookup(resourceId, member); } - break; } + break; } this.setState({ resourceId, pillType, member, room }); } - componentDidMount() { - this._unmounted = false; - this._matrixClient = MatrixClientPeg.get(); + public componentDidMount(): void { + this.unmounted = false; + this.matrixClient = MatrixClientPeg.get(); // eslint-disable-next-line new-cap this.UNSAFE_componentWillReceiveProps(this.props); // HACK: We shouldn't be calling lifecycle functions ourselves. } - componentWillUnmount() { - this._unmounted = true; + public componentWillUnmount(): void { + this.unmounted = true; } - onMouseOver = () => { + private onMouseOver = (): void => { this.setState({ hover: true, }); }; - onMouseLeave = () => { + private onMouseLeave = (): void => { this.setState({ hover: false, }); }; - doProfileLookup(userId, member) { + private doProfileLookup(userId: string, member): void { MatrixClientPeg.get().getProfileInfo(userId).then((resp) => { - if (this._unmounted) { + if (this.unmounted) { return; } member.name = resp.displayname; @@ -173,7 +188,7 @@ class Pill extends React.Component { }); } - onUserPillClicked = (e) => { + private onUserPillClicked = (e): void => { e.preventDefault(); dis.dispatch({ action: Action.ViewUser, @@ -181,7 +196,7 @@ class Pill extends React.Component { }); }; - render() { + public render(): JSX.Element { const resource = this.state.resourceId; let avatar = null; @@ -191,7 +206,7 @@ class Pill extends React.Component { let href = this.props.url; let onClick; switch (this.state.pillType) { - case Pill.TYPE_AT_ROOM_MENTION: { + case PillType.AtRoomMention: { const room = this.props.room; if (room) { linkText = "@room"; @@ -200,9 +215,9 @@ class Pill extends React.Component { } pillClass = 'mx_AtRoomPill'; } - break; } - case Pill.TYPE_USER_MENTION: { + break; + case PillType.UserMention: { // If this user is not a member of this room, default to the empty member const member = this.state.member; if (member) { @@ -210,15 +225,15 @@ class Pill extends React.Component { member.rawDisplayName = member.rawDisplayName || ''; linkText = member.rawDisplayName; if (this.props.shouldShowPillAvatar) { - avatar =
    ; } diff --git a/src/components/views/elements/Tooltip.tsx b/src/components/views/elements/Tooltip.tsx index 189bee393f3..8d817551d0e 100644 --- a/src/components/views/elements/Tooltip.tsx +++ b/src/components/views/elements/Tooltip.tsx @@ -32,6 +32,8 @@ export enum Alignment { Top, // Centered Bottom, // Centered InnerBottom, // Inside the target, at the bottom + TopRight, // On top of the target, right aligned + TopCenter, // On top of the target, center aligned } export interface ITooltipProps { @@ -114,12 +116,12 @@ export default class Tooltip extends React.Component { ? Math.min(parentBox.width, this.props.maxParentWidth) : parentBox.width ); - const baseTop = (parentBox.top - 2 + this.props.yOffset) + window.pageYOffset; + const baseTop = (parentBox.top - 2 + this.props.yOffset) + window.scrollY; const top = baseTop + offset; - const right = width - parentBox.left - window.pageXOffset; - const left = parentBox.right + window.pageXOffset; + const right = width - parentBox.left - window.scrollX; + const left = parentBox.right + window.scrollX; const horizontalCenter = ( - parentBox.left - window.pageXOffset + (parentWidth / 2) + parentBox.left - window.scrollX + (parentWidth / 2) ); switch (this.props.alignment) { case Alignment.Natural: @@ -149,6 +151,16 @@ export default class Tooltip extends React.Component { style.top = baseTop + parentBox.height - 50; style.left = horizontalCenter; style.transform = "translate(-50%)"; + break; + case Alignment.TopRight: + style.top = baseTop - 5; + style.right = width - parentBox.right - window.scrollX; + style.transform = "translate(5px, -100%)"; + break; + case Alignment.TopCenter: + style.top = baseTop - 5; + style.left = horizontalCenter; + style.transform = "translate(-50%, -100%)"; } return style; diff --git a/src/components/views/emojipicker/Category.tsx b/src/components/views/emojipicker/Category.tsx index 7cd5b96bee8..d4ea4e89ffb 100644 --- a/src/components/views/emojipicker/Category.tsx +++ b/src/components/views/emojipicker/Category.tsx @@ -52,7 +52,7 @@ class Category extends React.PureComponent { const { onClick, onMouseEnter, onMouseLeave, selectedEmojis, emojis } = this.props; const emojisForRow = emojis.slice(rowIndex * 8, (rowIndex + 1) * 8); return (
    { - emojisForRow.map(emoji => (( + emojisForRow.map(emoji => ( { onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} /> - ))) + )) }
    ); }; diff --git a/src/components/views/emojipicker/EmojiPicker.tsx b/src/components/views/emojipicker/EmojiPicker.tsx index e064df405a4..461ef4f079a 100644 --- a/src/components/views/emojipicker/EmojiPicker.tsx +++ b/src/components/views/emojipicker/EmojiPicker.tsx @@ -249,7 +249,7 @@ class EmojiPicker extends React.Component { > { this.categories.map(category => { const emojis = this.memoizedDataByCategory[category.id]; - const categoryElement = (( + const categoryElement = ( { onMouseLeave={this.onHoverEmojiEnd} selectedEmojis={this.props.selectedEmojis} /> - )); + ); const height = EmojiPicker.categoryHeightForEmojiCount(emojis.length); heightBefore += height; return categoryElement; diff --git a/src/components/views/emojipicker/QuickReactions.tsx b/src/components/views/emojipicker/QuickReactions.tsx index c0336a759de..f53b30f565d 100644 --- a/src/components/views/emojipicker/QuickReactions.tsx +++ b/src/components/views/emojipicker/QuickReactions.tsx @@ -72,7 +72,7 @@ class QuickReactions extends React.Component { }
      - { QUICK_REACTIONS.map(emoji => (( + { QUICK_REACTIONS.map(emoji => ( { onMouseLeave={this.onMouseLeave} selectedEmojis={this.props.selectedEmojis} /> - ))) } + )) }
    ); diff --git a/src/components/views/location/EnableLiveShare.tsx b/src/components/views/location/EnableLiveShare.tsx new file mode 100644 index 00000000000..30a48746867 --- /dev/null +++ b/src/components/views/location/EnableLiveShare.tsx @@ -0,0 +1,63 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the 'License'); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an 'AS IS' BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useState } from 'react'; + +import { _t } from '../../../languageHandler'; +import StyledLiveBeaconIcon from '../beacon/StyledLiveBeaconIcon'; +import AccessibleButton from '../elements/AccessibleButton'; +import LabelledToggleSwitch from '../elements/LabelledToggleSwitch'; +import Heading from '../typography/Heading'; + +interface Props { + onSubmit: () => void; +} + +export const EnableLiveShare: React.FC = ({ + onSubmit, +}) => { + const [isEnabled, setEnabled] = useState(false); + return ( +
    + + { _t('Live location sharing') } +

    + { _t( + 'Please note: this is a labs feature using a temporary implementation. ' + + 'This means you will not be able to delete your location history, ' + + 'and advanced users will be able to see your location history ' + + 'even after you stop sharing your live location with this room.', + ) } +

    + + + { _t('OK') } + +
    + ); +}; diff --git a/src/components/views/location/LocationPicker.tsx b/src/components/views/location/LocationPicker.tsx index 254ec335cbd..c7fb5cd3148 100644 --- a/src/components/views/location/LocationPicker.tsx +++ b/src/components/views/location/LocationPicker.tsx @@ -87,7 +87,7 @@ class LocationPicker extends React.Component { positionOptions: { enableHighAccuracy: true, }, - trackUserLocation: true, + trackUserLocation: false, }); this.map.addControl(this.geolocate); diff --git a/src/components/views/location/LocationShareMenu.tsx b/src/components/views/location/LocationShareMenu.tsx index 7b102f4b0f0..795a7802375 100644 --- a/src/components/views/location/LocationShareMenu.tsx +++ b/src/components/views/location/LocationShareMenu.tsx @@ -21,12 +21,14 @@ import { IEventRelation } from 'matrix-js-sdk/src/models/event'; import MatrixClientContext from '../../../contexts/MatrixClientContext'; import ContextMenu, { AboveLeftOf } from '../../structures/ContextMenu'; import LocationPicker, { ILocationPickerProps } from "./LocationPicker"; -import { shareLiveLocation, shareLocation } from './shareLocation'; +import { shareLiveLocation, shareLocation, LocationShareType } from './shareLocation'; import SettingsStore from '../../../settings/SettingsStore'; import ShareDialogButtons from './ShareDialogButtons'; import ShareType from './ShareType'; -import { LocationShareType } from './shareLocation'; import { OwnProfileStore } from '../../../stores/OwnProfileStore'; +import { EnableLiveShare } from './EnableLiveShare'; +import { useFeatureEnabled } from '../../../hooks/useSettings'; +import { SettingLevel } from '../../../settings/SettingLevel'; type Props = Omit & { onFinished: (ev?: SyntheticEvent) => void; @@ -37,11 +39,7 @@ type Props = Omit & { }; const getEnabledShareTypes = (): LocationShareType[] => { - const enabledShareTypes = [LocationShareType.Own]; - - if (SettingsStore.getValue("feature_location_share_live")) { - enabledShareTypes.push(LocationShareType.Live); - } + const enabledShareTypes = [LocationShareType.Own, LocationShareType.Live]; if (SettingsStore.getValue("feature_location_share_pin_drop")) { enabledShareTypes.push(LocationShareType.Pin); @@ -60,6 +58,7 @@ const LocationShareMenu: React.FC = ({ }) => { const matrixClient = useContext(MatrixClientContext); const enabledShareTypes = getEnabledShareTypes(); + const isLiveShareEnabled = useFeatureEnabled("feature_location_share_live"); const multipleShareTypesEnabled = enabledShareTypes.length > 1; @@ -73,19 +72,32 @@ const LocationShareMenu: React.FC = ({ shareLiveLocation(matrixClient, roomId, displayName, openMenu) : shareLocation(matrixClient, roomId, shareType, relation, openMenu); + const onLiveShareEnableSubmit = () => { + SettingsStore.setValue("feature_location_share_live", undefined, SettingLevel.DEVICE, true); + }; + + const shouldAdvertiseLiveLabsFlag = shareType === LocationShareType.Live && !isLiveShareEnabled; + return
    - { shareType ? + { shouldAdvertiseLiveLabsFlag && + + } + { !shouldAdvertiseLiveLabsFlag && !!shareType && : + /> + } + { !shareType && } setShareType(undefined)} onCancel={onFinished} /> diff --git a/src/components/views/location/Map.tsx b/src/components/views/location/Map.tsx index 8776e8e8264..023ff2d5ccb 100644 --- a/src/components/views/location/Map.tsx +++ b/src/components/views/location/Map.tsx @@ -16,6 +16,7 @@ limitations under the License. import React, { ReactNode, useContext, useEffect } from 'react'; import classNames from 'classnames'; +import maplibregl from 'maplibre-gl'; import { ClientEvent, IClientWellKnown } from 'matrix-js-sdk/src/matrix'; import { logger } from 'matrix-js-sdk/src/logger'; @@ -24,8 +25,9 @@ import { useEventEmitterState } from '../../../hooks/useEventEmitter'; import { parseGeoUri } from '../../../utils/location'; import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils'; import { useMap } from '../../../utils/location/useMap'; +import { Bounds } from '../../../utils/beacon/bounds'; -const useMapWithStyle = ({ id, centerGeoUri, onError, interactive }) => { +const useMapWithStyle = ({ id, centerGeoUri, onError, interactive, bounds }) => { const bodyId = `mx_Map_${id}`; // style config @@ -49,12 +51,26 @@ const useMapWithStyle = ({ id, centerGeoUri, onError, interactive }) => { try { const coords = parseGeoUri(centerGeoUri); map.setCenter({ lon: coords.longitude, lat: coords.latitude }); - } catch (error) { - logger.error('Could not set map center', centerGeoUri); + } catch (_error) { + logger.error('Could not set map center'); } } }, [map, centerGeoUri]); + useEffect(() => { + if (map && bounds) { + try { + const lngLatBounds = new maplibregl.LngLatBounds( + [bounds.west, bounds.south], + [bounds.east, bounds.north], + ); + map.fitBounds(lngLatBounds, { padding: 100, maxZoom: 15 }); + } catch (_error) { + logger.error('Invalid map bounds'); + } + } + }, [map, bounds]); + return { map, bodyId, @@ -65,6 +81,7 @@ interface MapProps { id: string; interactive?: boolean; centerGeoUri?: string; + bounds?: Bounds; className?: string; onClick?: () => void; onError?: (error: Error) => void; @@ -74,9 +91,15 @@ interface MapProps { } const Map: React.FC = ({ - centerGeoUri, className, id, onError, onClick, children, interactive, + bounds, + centerGeoUri, + children, + className, + id, + interactive, + onError, onClick, }) => { - const { map, bodyId } = useMapWithStyle({ centerGeoUri, onError, id, interactive }); + const { map, bodyId } = useMapWithStyle({ centerGeoUri, onError, id, interactive, bounds }); const onMapClick = ( event: React.MouseEvent, diff --git a/src/components/views/location/shareLocation.ts b/src/components/views/location/shareLocation.ts index 6654a389a06..895f2c23c6c 100644 --- a/src/components/views/location/shareLocation.ts +++ b/src/components/views/location/shareLocation.ts @@ -25,6 +25,7 @@ import { _t } from "../../../languageHandler"; import Modal from "../../../Modal"; import QuestionDialog from "../dialogs/QuestionDialog"; import SdkConfig from "../../../SdkConfig"; +import { OwnBeaconStore } from "../../../stores/OwnBeaconStore"; export enum LocationShareType { Own = 'Own', @@ -70,7 +71,7 @@ export const shareLiveLocation = ( ): ShareLocationFn => async ({ timeout }) => { const description = _t(`%(displayName)s's live location`, { displayName }); try { - await client.unstable_createLiveBeacon( + await OwnBeaconStore.instance.createLiveBeacon( roomId, makeBeaconInfoContent( timeout ?? DEFAULT_LIVE_DURATION, diff --git a/src/components/views/messages/MBeaconBody.tsx b/src/components/views/messages/MBeaconBody.tsx index f61ec346e4f..fb82cff29e2 100644 --- a/src/components/views/messages/MBeaconBody.tsx +++ b/src/components/views/messages/MBeaconBody.tsx @@ -32,8 +32,8 @@ import Spinner from '../elements/Spinner'; import Map from '../location/Map'; import SmartMarker from '../location/SmartMarker'; import OwnBeaconStatus from '../beacon/OwnBeaconStatus'; -import { IBodyProps } from "./IBodyProps"; import BeaconViewDialog from '../beacon/BeaconViewDialog'; +import { IBodyProps } from "./IBodyProps"; const useBeaconState = (beaconInfoEvent: MatrixEvent): { beacon?: Beacon; @@ -105,6 +105,7 @@ const MBeaconBody: React.FC = React.forwardRef(({ mxEvent }, ref) => { roomId: mxEvent.getRoomId(), matrixClient, + focusBeacon: beacon, }, "mx_BeaconViewDialog_wrapper", false, // isPriority @@ -145,12 +146,14 @@ const MBeaconBody: React.FC = React.forwardRef(({ mxEvent }, ref) => className='mx_MBeaconBody_chin' beacon={beacon} displayStatus={displayStatus} + withIcon /> : }
    diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx index 5ed699b2327..73c59472fdd 100644 --- a/src/components/views/messages/MImageBody.tsx +++ b/src/components/views/messages/MImageBody.tsx @@ -138,7 +138,7 @@ export default class MImageBody extends React.Component { } }; - private onImageEnter = (e: React.MouseEvent): void => { + protected onImageEnter = (e: React.MouseEvent): void => { this.setState({ hover: true }); if (!this.state.showImage || !this.state.isAnimated || SettingsStore.getValue("autoplayGifs")) { @@ -148,7 +148,7 @@ export default class MImageBody extends React.Component { imgElement.src = this.state.contentUrl; }; - private onImageLeave = (e: React.MouseEvent): void => { + protected onImageLeave = (e: React.MouseEvent): void => { this.setState({ hover: false }); if (!this.state.showImage || !this.state.isAnimated || SettingsStore.getValue("autoplayGifs")) { diff --git a/src/components/views/messages/MLocationBody.tsx b/src/components/views/messages/MLocationBody.tsx index 94abc1c7a88..ff87af1dc3a 100644 --- a/src/components/views/messages/MLocationBody.tsx +++ b/src/components/views/messages/MLocationBody.tsx @@ -96,7 +96,7 @@ export const LocationBodyFallbackContent: React.FC<{ event: MatrixEvent, error: (_t('Shared their location: ') + event.getContent()?.body) : (_t('Shared a location: ') + event.getContent()?.body); - return
    + return
    { message } diff --git a/src/components/views/messages/MStickerBody.tsx b/src/components/views/messages/MStickerBody.tsx index eb56d8d2e5f..d754f04b08a 100644 --- a/src/components/views/messages/MStickerBody.tsx +++ b/src/components/views/messages/MStickerBody.tsx @@ -39,11 +39,19 @@ export default class MStickerBody extends MImageBody { return
    { children }
    ; } - // Placeholder to show in place of the sticker image if - // img onLoad hasn't fired yet. + // Placeholder to show in place of the sticker image if img onLoad hasn't fired yet. protected getPlaceholder(width: number, height: number): JSX.Element { if (this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]) return super.getPlaceholder(width, height); - return ; + return ( + + ); } // Tooltip to show on mouse over diff --git a/src/components/views/messages/MVideoBody.tsx b/src/components/views/messages/MVideoBody.tsx index f4733df19f3..1ac389a6bc1 100644 --- a/src/components/views/messages/MVideoBody.tsx +++ b/src/components/views/messages/MVideoBody.tsx @@ -281,6 +281,9 @@ export default class MVideoBody extends React.PureComponent src={contentUrl} title={content.body} controls + // Disable downloading as it doesn't work with e2ee video, + // users should use the dedicated Download button in the Message Action Bar + controlsList="nodownload" preload={preload} muted={autoplay} autoPlay={autoplay} diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index 7bca48097c7..0effb252888 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -26,11 +26,11 @@ import type { Relations } from 'matrix-js-sdk/src/models/relations'; import { _t } from '../../../languageHandler'; import dis from '../../../dispatcher/dispatcher'; import ContextMenu, { aboveLeftOf, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu'; -import { isContentActionable, canEditContent, editEvent } from '../../../utils/EventUtils'; +import { isContentActionable, canEditContent, editEvent, canCancel } from '../../../utils/EventUtils'; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import Toolbar from "../../../accessibility/Toolbar"; import { RovingAccessibleTooltipButton, useRovingTabIndex } from "../../../accessibility/RovingTabIndex"; -import MessageContextMenu, { canCancel } from "../context_menus/MessageContextMenu"; +import MessageContextMenu from "../context_menus/MessageContextMenu"; import Resend from "../../../Resend"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; @@ -308,6 +308,11 @@ export default class MessageActionBar extends React.PureComponent { @@ -54,6 +54,10 @@ interface IProps extends Omit implements IMediaBody, IOperableEventTile { private body: React.RefObject = createRef(); private mediaHelper: MediaEventHelper; diff --git a/src/components/views/messages/UnknownBody.tsx b/src/components/views/messages/UnknownBody.tsx index d9e70ff241f..cd1f06a788b 100644 --- a/src/components/views/messages/UnknownBody.tsx +++ b/src/components/views/messages/UnknownBody.tsx @@ -23,12 +23,12 @@ interface IProps { children?: React.ReactNode; } -export default forwardRef(({ mxEvent, children }: IProps, ref: React.RefObject) => { +export default forwardRef(({ mxEvent, children }: IProps, ref: React.RefObject) => { const text = mxEvent.getContent().body; return ( - +
    { text } { children } - +
    ); }); diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index 018d2c6927f..fd69f46ef9c 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -207,11 +207,8 @@ const AppsSection: React.FC = ({ room }) => { if (!managers.hasManager()) { managers.openNoManagerDialog(); } else { - if (SettingsStore.getValue("feature_many_integration_managers")) { - managers.openAll(room); - } else { - managers.getPrimaryManager().open(room); - } + // noinspection JSIgnoredPromiseFromCall + managers.getPrimaryManager().open(room); } }; diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 934f88b0a74..7b45746c770 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -75,7 +75,6 @@ import { UIComponent } from "../../../settings/UIFeature"; import { TimelineRenderingType } from "../../../contexts/RoomContext"; import RightPanelStore from '../../../stores/right-panel/RightPanelStore'; import { IRightPanelCardState } from '../../../stores/right-panel/RightPanelStoreIPanelState'; -import { useUserStatusMessage } from "../../../hooks/useUserStatusMessage"; import UserIdentifierCustomisations from '../../../customisations/UserIdentifier'; import PosthogTrackers from "../../../PosthogTrackers"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; @@ -292,13 +291,17 @@ function DevicesSection({ devices, userId, loading }: { devices: IDevice[], user let expandButton; if (expandSectionDevices.length) { if (isExpanded) { - expandButton = ( setExpanded(false)} >
    { expandHideCaption }
    ); } else { - expandButton = ( setExpanded(true)} >
    @@ -331,6 +334,7 @@ const MessageButton = ({ userId }: { userId: string }) => { return ( { if (busy) return; setBusy(true); @@ -383,6 +387,7 @@ const UserOptionsSection: React.FC<{ ignoreButton = ( @@ -413,14 +418,22 @@ const UserOptionsSection: React.FC<{ const room = cli.getRoom(member.roomId); if (room?.getEventReadUpTo(member.userId)) { readReceiptButton = ( - + { _t('Jump to read receipt') } ); } insertPillButton = ( - + { _t('Mention') } ); @@ -448,7 +461,11 @@ const UserOptionsSection: React.FC<{ }; inviteUserButton = ( - + { _t('Invite') } ); @@ -456,7 +473,11 @@ const UserOptionsSection: React.FC<{ } const shareUserButton = ( - + { _t('Share Link to User') } ); @@ -575,7 +596,9 @@ const RoomKickButton = ({ room, member, startUpdating, stopUpdating }: Omit + const kickLabel = room.isSpaceRoom() ? + member.membership === "invite" ? _t("Disinvite from space") : _t("Remove from space") + : member.membership === "invite" ? _t("Disinvite from room") : _t("Remove from room"); + + return { kickLabel } ; }; @@ -637,7 +667,11 @@ const RedactMessagesButton: React.FC = ({ member }) => { }); }; - return + return { _t("Remove recent messages") } ; }; @@ -734,7 +768,11 @@ const BanToggleButton = ({ room, member, startUpdating, stopUpdating }: Omit + return { label } ; }; @@ -804,7 +842,11 @@ const MuteToggleButton: React.FC = ({ member, room, powerLevels, }); const muteLabel = muted ? _t("Unmute") : _t("Mute"); - return + return { muteLabel } ; }; @@ -921,14 +963,9 @@ function useRoomPermissions(cli: MatrixClient, room: Room, user: RoomMember): IR canEdit: false, canInvite: false, }); - const updateRoomPermissions = useCallback(() => { - if (!room) { - return; - } - const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); - if (!powerLevelEvent) return; - const powerLevels = powerLevelEvent.getContent(); + const updateRoomPermissions = useCallback(() => { + const powerLevels = room?.currentState.getStateEvents(EventType.RoomPowerLevels, "")?.getContent(); if (!powerLevels) return; const me = room.getMember(cli.getUserId()); @@ -940,17 +977,14 @@ function useRoomPermissions(cli: MatrixClient, room: Room, user: RoomMember): IR let modifyLevelMax = -1; if (canAffectUser) { - const editPowerLevel = ( - (powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) || - powerLevels.state_default - ); - if (me.powerLevel >= editPowerLevel && (isMe || me.powerLevel > them.powerLevel)) { + const editPowerLevel = powerLevels.events?.[EventType.RoomPowerLevels] ?? powerLevels.state_default ?? 50; + if (me.powerLevel >= editPowerLevel) { modifyLevelMax = me.powerLevel; } } setRoomPermissions({ - canInvite: me.powerLevel >= powerLevels.invite, + canInvite: me.powerLevel >= (powerLevels.invite ?? 0), canEdit: modifyLevelMax >= 0, modifyLevelMax, }); @@ -1215,7 +1249,11 @@ const BasicUserInfo: React.FC<{ // FIXME this should be using cli instead of MatrixClientPeg.matrixClient if (isSynapseAdmin && member.userId.endsWith(`:${MatrixClientPeg.getHomeserverName()}`)) { synapseDeactivateButton = ( - + { _t("Deactivate user") } ); @@ -1293,8 +1331,9 @@ const BasicUserInfo: React.FC<{ if (canVerify) { if (hasCrossSigningKeys !== undefined) { // Note: mx_UserInfo_verifyButton is for the end-to-end tests - verifyButton = ( + verifyButton = (
    { if (hasCrossSigningKeys) { @@ -1306,7 +1345,7 @@ const BasicUserInfo: React.FC<{ > { _t("Verify") } - ); +
    ); } else if (!showDeviceListSpinner) { // HACK: only show a spinner if the device section spinner is not shown, // to avoid showing a double spinner @@ -1319,6 +1358,7 @@ const BasicUserInfo: React.FC<{ if (member.userId == cli.getUserId()) { editDevices = (
    { dis.dispatch({ @@ -1370,7 +1410,6 @@ const UserInfoHeader: React.FC<{ roomId?: string; }> = ({ member, e2eStatus, roomId }) => { const cli = useContext(MatrixClientContext); - const statusMessage = useUserStatusMessage(member); const onMemberAvatarClick = useCallback(() => { const avatarUrl = (member as RoomMember).getMxcAvatarUrl @@ -1431,11 +1470,6 @@ const UserInfoHeader: React.FC<{ ); } - let statusLabel = null; - if (statusMessage) { - statusLabel = { statusMessage }; - } - let e2eIcon; if (e2eStatus) { e2eIcon = ; @@ -1458,7 +1492,6 @@ const UserInfoHeader: React.FC<{
    { UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, { roomId, withDisplayName: true }) }
    { presenceLabel } - { statusLabel }
    diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index a5dcf038133..068d096624f 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -32,7 +32,7 @@ import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize'; import { renderModel } from '../../../editor/render'; import TypingStore from "../../../stores/TypingStore"; import SettingsStore from "../../../settings/SettingsStore"; -import { Key } from "../../../Keyboard"; +import { IS_MAC, Key } from "../../../Keyboard"; import { EMOTICON_TO_EMOJI } from "../../../emoji"; import { CommandCategories, CommandMap, parseCommandString } from "../../../SlashCommands"; import Range from "../../../editor/range"; @@ -50,8 +50,6 @@ import { _t } from "../../../languageHandler"; const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s|:^$'); export const REGEX_EMOTICON = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')$'); -const IS_MAC = navigator.platform.indexOf("Mac") !== -1; - const SURROUND_WITH_CHARACTERS = ["\"", "_", "`", "'", "*", "~", "$"]; const SURROUND_WITH_DOUBLE_CHARACTERS = new Map([ ["(", ")"], @@ -103,6 +101,7 @@ interface IProps { } interface IState { + useMarkdown: boolean; showPillAvatar: boolean; query?: string; showVisualBell?: boolean; @@ -124,6 +123,7 @@ export default class BasicMessageEditor extends React.Component private lastCaret: DocumentOffset; private lastSelection: ReturnType; + private readonly useMarkdownHandle: string; private readonly emoticonSettingHandle: string; private readonly shouldShowPillAvatarSettingHandle: string; private readonly surroundWithHandle: string; @@ -133,10 +133,13 @@ export default class BasicMessageEditor extends React.Component super(props); this.state = { showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"), + useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"), surroundWith: SettingsStore.getValue("MessageComposerInput.surroundWith"), showVisualBell: false, }; + this.useMarkdownHandle = SettingsStore.watchSetting('MessageComposerInput.useMarkdown', null, + this.configureUseMarkdown); this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null, this.configureEmoticonAutoReplace); this.configureEmoticonAutoReplace(); @@ -442,7 +445,7 @@ export default class BasicMessageEditor extends React.Component } } else if (!selection.isCollapsed && !isEmpty) { this.hasTextSelected = true; - if (this.formatBarRef.current) { + if (this.formatBarRef.current && this.state.useMarkdown) { const selectionRect = selection.getRangeAt(0).getBoundingClientRect(); this.formatBarRef.current.showAt(selectionRect); } @@ -630,6 +633,14 @@ export default class BasicMessageEditor extends React.Component this.setState({ completionIndex }); }; + private configureUseMarkdown = (): void => { + const useMarkdown = SettingsStore.getValue("MessageComposerInput.useMarkdown"); + this.setState({ useMarkdown }); + if (!useMarkdown && this.formatBarRef.current) { + this.formatBarRef.current.hide(); + } + }; + private configureEmoticonAutoReplace = (): void => { this.props.model.setTransformCallback(this.transform); }; @@ -654,6 +665,7 @@ export default class BasicMessageEditor extends React.Component this.editorRef.current.removeEventListener("input", this.onInput, true); this.editorRef.current.removeEventListener("compositionstart", this.onCompositionStart, true); this.editorRef.current.removeEventListener("compositionend", this.onCompositionEnd, true); + SettingsStore.unwatchSetting(this.useMarkdownHandle); SettingsStore.unwatchSetting(this.emoticonSettingHandle); SettingsStore.unwatchSetting(this.shouldShowPillAvatarSettingHandle); SettingsStore.unwatchSetting(this.surroundWithHandle); @@ -694,6 +706,10 @@ export default class BasicMessageEditor extends React.Component } public onFormatAction = (action: Formatting): void => { + if (!this.state.useMarkdown) { + return; + } + const range: Range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection()); this.historyManager.ensureLastChangesPushed(this.props.model); diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index ce6d1b844e0..de1bdc9c85b 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -95,7 +95,10 @@ function createEditContent( body: `${plainPrefix} * ${body}`, }; - const formattedBody = htmlSerializeIfNeeded(model, { forceHTML: isReply }); + const formattedBody = htmlSerializeIfNeeded(model, { + forceHTML: isReply, + useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"), + }); if (formattedBody) { newContent.format = "org.matrix.custom.html"; newContent.formatted_body = formattedBody; @@ -404,7 +407,9 @@ class EditMessageComposer extends React.Component Relations; @@ -92,10 +96,19 @@ export type GetRelationsForEvent = (eventId: string, relationType: string, event export interface IReadReceiptProps { userId: string; - roomMember: RoomMember; + roomMember: RoomMember | null; ts: number; } +export interface IEventTileOps { + isWidgetHidden(): boolean; + unhideWidget(): void; +} + +export interface IEventTileType extends React.Component { + getEventTileOps?(): IEventTileOps; +} + interface IProps { // the MatrixEvent to show mxEvent: MatrixEvent; @@ -209,9 +222,6 @@ interface IProps { interface IState { // Whether the action bar is focused. actionBarFocused: boolean; - // Whether all read receipts are being displayed. If not, only display - // a truncation of them. - allReadAvatars: boolean; // Whether the event's sender has been verified. verified: string; // Whether onRequestKeysClick has been called since mounting. @@ -220,6 +230,13 @@ interface IState { reactions: Relations; hover: boolean; + + // Position of the context menu + contextMenu?: { + position: Pick; + showPermalink?: boolean; + }; + isQuoteExpanded?: boolean; thread: Thread; @@ -230,8 +247,7 @@ interface IState { export class UnwrappedEventTile extends React.Component { private suppressReadReceiptAnimation: boolean; private isListeningForReceipts: boolean; - // TODO: Types - private tile = React.createRef(); + private tile = React.createRef(); private replyChain = React.createRef(); private threadState: ThreadNotificationState; @@ -255,15 +271,14 @@ export class UnwrappedEventTile extends React.Component { this.state = { // Whether the action bar is focused. actionBarFocused: false, - // Whether all read receipts are being displayed. If not, only display - // a truncation of them. - allReadAvatars: false, // Whether the event's sender has been verified. verified: null, // Whether onRequestKeysClick has been called since mounting. previouslyRequestedKeys: false, // The Relations model from the JS SDK for reactions to `mxEvent` reactions: this.getReactions(), + // Context menu position + contextMenu: null, hover: false, @@ -387,8 +402,7 @@ export class UnwrappedEventTile extends React.Component { } private setupNotificationListener = (thread: Thread): void => { - const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); - const notifications = RoomNotificationStateStore.instance.getThreadsRoomState(room); + const notifications = RoomNotificationStateStore.instance.getThreadsRoomState(thread.room); this.threadState = notifications.getThreadRoomState(thread); @@ -484,16 +498,18 @@ export class UnwrappedEventTile extends React.Component { return null; } + let thread = this.props.mxEvent.getThread(); /** * Accessing the threads value through the room due to a race condition * that will be solved when there are proper backend support for threads * We currently have no reliable way to discover than an event is a thread * when we are at the sync stage */ - const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); - const thread = room?.threads?.get(this.props.mxEvent.getId()); - - return thread || null; + if (!thread) { + const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); + thread = room?.findThreadForEvent(this.props.mxEvent); + } + return thread ?? null; } private renderThreadPanelSummary(): JSX.Element | null { @@ -502,7 +518,7 @@ export class UnwrappedEventTile extends React.Component { } return
    - + { this.state.thread.length } @@ -711,108 +727,6 @@ export class UnwrappedEventTile extends React.Component { return actions.tweaks.highlight; } - private toggleAllReadAvatars = () => { - this.setState({ - allReadAvatars: !this.state.allReadAvatars, - }); - }; - - private getReadAvatars() { - if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) { - return ; - } - - const MAX_READ_AVATARS = this.props.layout == Layout.Bubble - ? 2 - : 5; - - // return early if there are no read receipts - if (!this.props.readReceipts || this.props.readReceipts.length === 0) { - // We currently must include `mx_EventTile_readAvatars` in the DOM - // of all events, as it is the positioned parent of the animated - // read receipts. We can't let it unmount when a receipt moves - // events, so for now we mount it for all events. Without it, the - // animation will start from the top of the timeline (because it - // lost its container). - // See also https://github.com/vector-im/element-web/issues/17561 - return ( -
    - -
    - ); - } - - const avatars = []; - const receiptOffset = 15; - let left = 0; - - const receipts = this.props.readReceipts; - - for (let i = 0; i < receipts.length; ++i) { - const receipt = receipts[i]; - - let hidden = true; - if ((i < MAX_READ_AVATARS) || this.state.allReadAvatars) { - hidden = false; - } - // TODO: we keep the extra read avatars in the dom to make animation simpler - // we could optimise this to reduce the dom size. - - // If hidden, set offset equal to the offset of the final visible avatar or - // else set it proportional to index - left = (hidden ? MAX_READ_AVATARS - 1 : i) * -receiptOffset; - - const userId = receipt.userId; - let readReceiptInfo: IReadReceiptInfo; - - if (this.props.readReceiptMap) { - readReceiptInfo = this.props.readReceiptMap[userId]; - if (!readReceiptInfo) { - readReceiptInfo = {}; - this.props.readReceiptMap[userId] = readReceiptInfo; - } - } - - // add to the start so the most recent is on the end (ie. ends up rightmost) - avatars.unshift( -
    , , -
    +
    + { this.renderContextMenu() } { renderTile(TimelineRenderingType.Notification, { ...this.props, @@ -1298,7 +1304,8 @@ export class UnwrappedEventTile extends React.Component { { avatar } { sender }
    , -
    +
    + { this.renderContextMenu() } { replyChain } { renderTile(TimelineRenderingType.Thread, { ...this.props, @@ -1385,7 +1392,8 @@ export class UnwrappedEventTile extends React.Component { "aria-atomic": true, "data-scroll-tokens": scrollToken, }, [ -
    +
    + { this.renderContextMenu() } { renderTile(TimelineRenderingType.File, { ...this.props, @@ -1406,7 +1414,10 @@ export class UnwrappedEventTile extends React.Component { href={permalink} onClick={this.onPermalinkClicked} > -
    +
    { sender } { timestamp }
    @@ -1434,7 +1445,8 @@ export class UnwrappedEventTile extends React.Component { { sender } { ircPadlock } { avatar } -
    +
    + { this.renderContextMenu() } { groupTimestamp } { groupPadlock } { replyChain } @@ -1565,66 +1577,51 @@ interface ISentReceiptProps { messageState: string; // TODO: Types for message sending state } -interface ISentReceiptState { - hover: boolean; -} - -class SentReceipt extends React.PureComponent { - constructor(props) { - super(props); - - this.state = { - hover: false, - }; +function SentReceipt({ messageState }: ISentReceiptProps) { + const isSent = !messageState || messageState === 'sent'; + const isFailed = messageState === 'not_sent'; + const receiptClasses = classNames({ + 'mx_EventTile_receiptSent': isSent, + 'mx_EventTile_receiptSending': !isSent && !isFailed, + }); + + let nonCssBadge = null; + if (isFailed) { + nonCssBadge = ( + + ); } - onHoverStart = () => { - this.setState({ hover: true }); - }; - - onHoverEnd = () => { - this.setState({ hover: false }); - }; - - render() { - const isSent = !this.props.messageState || this.props.messageState === 'sent'; - const isFailed = this.props.messageState === 'not_sent'; - const receiptClasses = classNames({ - 'mx_EventTile_receiptSent': isSent, - 'mx_EventTile_receiptSending': !isSent && !isFailed, - }); - - let nonCssBadge = null; - if (isFailed) { - nonCssBadge = ; - } - - let tooltip = null; - if (this.state.hover) { - let label = _t("Sending your message..."); - if (this.props.messageState === 'encrypting') { - label = _t("Encrypting your message..."); - } else if (isSent) { - label = _t("Your message was sent"); - } else if (isFailed) { - label = _t("Failed to send"); - } - // The yOffset is somewhat arbitrary - it just brings the tooltip down to be more associated - // with the read receipt. - tooltip = ; - } + let label = _t("Sending your message..."); + if (messageState === 'encrypting') { + label = _t("Encrypting your message..."); + } else if (isSent) { + label = _t("Your message was sent"); + } else if (isFailed) { + label = _t("Failed to send"); + } + const [{ showTooltip, hideTooltip }, tooltip] = useTooltip({ + label: label, + alignment: Alignment.TopRight, + }); - return ( -
    - - - { nonCssBadge } - { tooltip } + return ( +
    +
    +
    + + + { nonCssBadge } + - +
    + { tooltip }
    - ); - } +
    + ); } diff --git a/src/components/views/rooms/MemberTile.tsx b/src/components/views/rooms/MemberTile.tsx index b652771a43e..f292ec3b589 100644 --- a/src/components/views/rooms/MemberTile.tsx +++ b/src/components/views/rooms/MemberTile.tsx @@ -20,12 +20,10 @@ import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { EventType } from "matrix-js-sdk/src/@types/event"; import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; -import { UserEvent } from "matrix-js-sdk/src/models/user"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { UserTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning'; -import SettingsStore from "../../../settings/SettingsStore"; import dis from "../../../dispatcher/dispatcher"; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from "../../../MatrixClientPeg"; @@ -41,7 +39,6 @@ interface IProps { } interface IState { - statusMessage: string; isRoomEncrypted: boolean; e2eStatus: string; } @@ -58,7 +55,6 @@ export default class MemberTile extends React.Component { super(props); this.state = { - statusMessage: this.getStatusMessage(), isRoomEncrypted: false, e2eStatus: null, }; @@ -67,13 +63,6 @@ export default class MemberTile extends React.Component { componentDidMount() { const cli = MatrixClientPeg.get(); - if (SettingsStore.getValue("feature_custom_status")) { - const { user } = this.props.member; - if (user) { - user.on(UserEvent._UnstableStatusMessage, this.onStatusMessageCommitted); - } - } - const { roomId } = this.props.member; if (roomId) { const isRoomEncrypted = cli.isRoomEncrypted(roomId); @@ -94,11 +83,6 @@ export default class MemberTile extends React.Component { componentWillUnmount() { const cli = MatrixClientPeg.get(); - const { user } = this.props.member; - if (user) { - user.removeListener(UserEvent._UnstableStatusMessage, this.onStatusMessageCommitted); - } - if (cli) { cli.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); cli.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged); @@ -158,21 +142,6 @@ export default class MemberTile extends React.Component { }); } - private getStatusMessage(): string { - const { user } = this.props.member; - if (!user) { - return ""; - } - return user.unstable_statusMessage; - } - - private onStatusMessageCommitted = (): void => { - // The `User` object has observed a status message change. - this.setState({ - statusMessage: this.getStatusMessage(), - }); - }; - shouldComponentUpdate(nextProps: IProps, nextState: IState): boolean { if ( this.memberLastModifiedTime === undefined || @@ -222,11 +191,6 @@ export default class MemberTile extends React.Component { const name = this.getDisplayName(); const presenceState = member.user ? member.user.presence : null; - let statusMessage = null; - if (member.user && SettingsStore.getValue("feature_custom_status")) { - statusMessage = this.state.statusMessage; - } - const av = (