diff --git a/.github/actions/download-verify-element-tarball/action.yml b/.github/actions/download-verify-element-tarball/action.yml index 40855b85c6..7a7265a2ae 100644 --- a/.github/actions/download-verify-element-tarball/action.yml +++ b/.github/actions/download-verify-element-tarball/action.yml @@ -11,7 +11,7 @@ runs: using: composite steps: - name: Download release tarball - uses: robinraju/release-downloader@daf26c55d821e836577a15f77d86ddc078948b05 # v1 + uses: robinraju/release-downloader@28fc21f50d76778e7023361aa1f863e717d3d56f # v1 with: tag: ${{ inputs.tag }} fileName: element-*.tar.gz* diff --git a/.github/renovate.json b/.github/renovate.json index 9bc8cd62b3..928cfd4ad0 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -2,25 +2,39 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["github>matrix-org/renovate-config-element-web"], "postUpdateOptions": ["pnpmDedupe"], + "stopUpdatingLabel": "X-Blocked", "packageRules": [ { + "description": "Group all testcontainers docker digests", "groupName": "testcontainers docker digests", "groupSlug": "testcontainers-docker", "matchDepTypes": ["testcontainers-docker"], "matchPackageNames": ["*"] + }, + { + "description": "Separate updates to overrides from other groups", + "matchDepTypes": ["pnpm.overrides"], + "groupSlug": null + }, + { + "description": "Disable any major updates to overrides as this almost always is wrong", + "matchDepTypes": ["pnpm.overrides"], + "matchUpdateTypes": ["major"], + "enabled": false } ], "customManagers": [ { + "description": "Update testcontainers docker digests", "customType": "regex", "datasourceTemplate": "docker", "versioningTemplate": "loose", - "description": "Update testcontainers docker digests", "managerFilePatterns": ["**/testcontainers/*.ts"], "matchStrings": ["\\s+\"(?[^@]+):(?[^@]+)@(?sha256:[a-f0-9]+)\""], "depTypeTemplate": "testcontainers-docker" }, { + "description": "Update element-desktop hakDependencies", "customType": "jsonata", "managerFilePatterns": ["/(^|/)package\\.json$/"], "fileFormat": "json", diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index eb469502d9..c1ecb609f8 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -64,7 +64,7 @@ jobs: persist-credentials: false - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: cache: "pnpm" node-version: "lts/*" @@ -146,7 +146,7 @@ jobs: path: apps/web/webapp - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: cache: "pnpm" cache-dependency-path: pnpm-lock.yaml @@ -206,6 +206,8 @@ jobs: needs: prepare_ed name: "Desktop Windows" uses: ./.github/workflows/build_desktop_windows.yaml + # Skip Windows builds on PRs, as the Linux amd64 build is enough of a smoke test and includes the screenshot tests + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'X-Run-All-Tests') strategy: matrix: arch: [x64, ia32, arm64] @@ -223,10 +225,13 @@ jobs: arch: [amd64, arm64] runAllTests: - ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'X-Run-All-Tests') }} - # We ship static sqlcipher builds, so delegate testing the system builds to the merge queue exclude: + # We ship static sqlcipher builds, so delegate testing the system builds to the merge queue - runAllTests: false sqlcipher: system + # Additionally skip arm64 system builds on PRs, as the amd64 test is enough for a smoke test and includes the screenshot tests + - runAllTests: false + arch: arm64 with: sqlcipher: ${{ matrix.sqlcipher }} arch: ${{ matrix.arch }} @@ -236,6 +241,9 @@ jobs: needs: prepare_ed name: "Desktop macOS" uses: ./.github/workflows/build_desktop_macos.yaml + # Skip macOS builds on PRs, as the Linux amd64 build is enough of a smoke test and includes the screenshot tests + # and we have a very low limit of concurrent macos runners (5) across the Github org. + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'X-Run-All-Tests') with: blob_report: true @@ -260,7 +268,7 @@ jobs: - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 if: needs.build_ew.outputs.skip == 'false' - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 if: needs.build_ew.outputs.skip == 'false' with: cache: "pnpm" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c63be7a419..5f93a94516 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,7 +48,7 @@ jobs: persist-credentials: false - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: # Disable cache on Windows as it is slower than not caching # https://github.com/actions/setup-node/issues/975 diff --git a/.github/workflows/build_desktop_linux.yaml b/.github/workflows/build_desktop_linux.yaml index 767fa06c10..436a5194ed 100644 --- a/.github/workflows/build_desktop_linux.yaml +++ b/.github/workflows/build_desktop_linux.yaml @@ -111,7 +111,7 @@ jobs: apps/desktop/.hak - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version-file: apps/desktop/.node-version cache: "pnpm" @@ -126,7 +126,7 @@ jobs: - name: "Get modified files" id: changed_files if: steps.cache.outputs.cache-hit != 'true' && github.event_name == 'pull_request' && github.repository == 'element-hq/element-web' - uses: tj-actions/changed-files@823fcebdb31bb35fdf2229d9f769b400309430d0 # v46 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47 with: files: | apps/desktop/dockerbuild/** diff --git a/.github/workflows/build_desktop_macos.yaml b/.github/workflows/build_desktop_macos.yaml index 384506518b..a32f674cfe 100644 --- a/.github/workflows/build_desktop_macos.yaml +++ b/.github/workflows/build_desktop_macos.yaml @@ -114,7 +114,7 @@ jobs: - run: sudo pip3 install pyobjc-framework-Quartz - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version-file: apps/desktop/.node-version cache: "pnpm" diff --git a/.github/workflows/build_desktop_prepare.yaml b/.github/workflows/build_desktop_prepare.yaml index be782f9dc1..2420e907cc 100644 --- a/.github/workflows/build_desktop_prepare.yaml +++ b/.github/workflows/build_desktop_prepare.yaml @@ -59,7 +59,7 @@ jobs: repository: element-hq/element-web - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version-file: apps/desktop/.node-version cache: "pnpm" diff --git a/.github/workflows/build_desktop_test.yaml b/.github/workflows/build_desktop_test.yaml index 613a72cfb0..1ec6d7bf73 100644 --- a/.github/workflows/build_desktop_test.yaml +++ b/.github/workflows/build_desktop_test.yaml @@ -42,7 +42,7 @@ jobs: persist-credentials: false - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version-file: apps/desktop/.node-version cache: "pnpm" @@ -97,6 +97,7 @@ jobs: PW_TAG: ${{ inputs.project }} ELEMENT_DESKTOP_EXECUTABLE: ${{ steps.executable.outputs.path }} ARGS: ${{ inputs.args }} + DEBUG: pw:browser - name: Upload blob report if: always() && inputs.blob_report diff --git a/.github/workflows/build_desktop_windows.yaml b/.github/workflows/build_desktop_windows.yaml index e893e5188c..2cbaef9c8f 100644 --- a/.github/workflows/build_desktop_windows.yaml +++ b/.github/workflows/build_desktop_windows.yaml @@ -153,7 +153,7 @@ jobs: TARGET: ${{ steps.config.outputs.target }} - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version-file: apps/desktop/.node-version cache: "pnpm" diff --git a/.github/workflows/build_develop.yml b/.github/workflows/build_develop.yml index a4c9873a27..246363b1df 100644 --- a/.github/workflows/build_develop.yml +++ b/.github/workflows/build_develop.yml @@ -33,7 +33,7 @@ jobs: persist-credentials: false - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: cache: "pnpm" node-version: "lts/*" diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index 137ab69e17..46800f02f0 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -31,7 +31,7 @@ jobs: persist-credentials: false - name: Install Cosign - uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 + uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 - name: Set up QEMU uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4 @@ -41,7 +41,7 @@ jobs: uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version-file: package.json cache: "pnpm" diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index c33629c2cd..97726eb6ef 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -26,7 +26,7 @@ jobs: persist-credentials: false - name: Install Cosign - uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 + uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 if: github.event_name != 'pull_request' - name: Set up QEMU diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0092b61f2c..9439989919 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -23,7 +23,7 @@ jobs: - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 with: package_json_file: package.json - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: cache: "pnpm" cache-dependency-path: pnpm-lock.yaml diff --git a/.github/workflows/npm-publish.yaml b/.github/workflows/npm-publish.yaml index 8ea61e4efa..55a1f37ed7 100644 --- a/.github/workflows/npm-publish.yaml +++ b/.github/workflows/npm-publish.yaml @@ -29,7 +29,7 @@ jobs: - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 - name: 🔧 Set up node environment - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: cache: "pnpm" node-version-file: ".node-version" diff --git a/.github/workflows/shared-component-storybook-build.yml b/.github/workflows/shared-component-storybook-build.yml index 53af5a5f7b..13b16a239e 100644 --- a/.github/workflows/shared-component-storybook-build.yml +++ b/.github/workflows/shared-component-storybook-build.yml @@ -18,7 +18,7 @@ jobs: - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 - name: 🔧 Pnpm cache - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: cache: "pnpm" node-version-file: package.json diff --git a/.github/workflows/shared-component-visual-tests-netlify.yaml b/.github/workflows/shared-component-visual-tests-netlify.yaml index e4b830406d..a1aa61b024 100644 --- a/.github/workflows/shared-component-visual-tests-netlify.yaml +++ b/.github/workflows/shared-component-visual-tests-netlify.yaml @@ -25,9 +25,6 @@ jobs: actions: read deployments: write steps: - - name: Install tree - run: "sudo apt-get install -y tree" - - name: Download Diffs uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: diff --git a/.github/workflows/shared-component-visual-tests.yaml b/.github/workflows/shared-component-visual-tests.yaml index 1d01119b04..6755197ee0 100644 --- a/.github/workflows/shared-component-visual-tests.yaml +++ b/.github/workflows/shared-component-visual-tests.yaml @@ -27,7 +27,7 @@ jobs: repository: element-hq/element-web - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: cache: "pnpm" node-version: "lts/*" @@ -45,11 +45,12 @@ jobs: working-directory: packages/shared-components run: "pnpm test:storybook --run" - # Workaround for vis silently adding new baselines if they didn't exist - # Can be removed once https://github.com/repobuddy/visual-testing/issues/516 is released - - run: | - git add -N . - git diff --exit-code + - name: Detect stale screenshots + run: | + if diff -rq __baselines__ __results__ | grep "^Only in __baselines__"; then + exit 1 + fi + working-directory: packages/shared-components/__vis__/linux - name: Upload received images & diffs if: always() diff --git a/.github/workflows/static_analysis.yaml b/.github/workflows/static_analysis.yaml index 51d7926b83..895ea83dc2 100644 --- a/.github/workflows/static_analysis.yaml +++ b/.github/workflows/static_analysis.yaml @@ -54,7 +54,7 @@ jobs: persist-credentials: false - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 if: matrix.install != '' with: cache: "pnpm" @@ -103,7 +103,7 @@ jobs: voip|element_call error|invalid_json error|misconfigured - welcome_to_element + welcome|title_element devtools|settings|elementCallUrl labs|sliding_sync_description settings|voip|noise_suppression_description diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4b0013f5cf..8a237b6950 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -45,7 +45,7 @@ jobs: - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 - name: pnpm cache - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: "lts/*" cache: "pnpm" @@ -137,7 +137,7 @@ jobs: - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 - name: pnpm cache - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: "lts/*" cache: "pnpm" @@ -163,6 +163,11 @@ jobs: working-directory: "packages/${{ matrix.package }}" run: pnpm test:unit --coverage=$ENABLE_COVERAGE + # Dump the disk usage on failure, because this job seems to fail with disk fills sometimes + - name: df + run: df + if: ${{ failure() }} + - name: Upload Artifact if: env.ENABLE_COVERAGE == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/.github/workflows/update-jitsi.yml b/.github/workflows/update-jitsi.yml index 2e52b08cbf..7d56d6d418 100644 --- a/.github/workflows/update-jitsi.yml +++ b/.github/workflows/update-jitsi.yml @@ -14,7 +14,7 @@ jobs: persist-credentials: false - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: cache: "pnpm" node-version: "lts/*" @@ -27,7 +27,7 @@ jobs: run: "pnpm vendor:jitsi" - name: Create Pull Request - uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8 with: token: ${{ secrets.ELEMENT_BOT_TOKEN }} branch: actions/jitsi-update diff --git a/.prettierignore b/.prettierignore index b6f2c7a1e7..e0a9e4fc57 100644 --- a/.prettierignore +++ b/.prettierignore @@ -50,6 +50,7 @@ CHANGELOG.md /apps/desktop/dist/ /apps/desktop/build/ /apps/desktop/dockerbuild/ +/apps/desktop/deploys/ /apps/desktop/lib/ /apps/desktop/webapp /apps/desktop/playwright/html-report diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f628628ce..615e496938 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,6 +49,21 @@ As for your PR description, it should include these things: Please **_do not use force push_** in your PRs. Doing so means we can't see what has changed. We use squash merge to get a "clean" git history. +### Adding a new feature or enhancement + +To make a great product with a great user experience, all the small efforts need to go in the same direction and be aligned and consistent with each other. + +Before making your contribution, please consider the following: + +- One product can’t do everything well. Element is focusing on private end-to-end encrypted messaging and voice - this can either be for consumers (e.g. friends and family) or for professional teams and organizations. Public forums and other types of chats without E2EE remain supported but are not the primary use case in case UX compromises need to be made. +- There are 3 platforms - Web/Desktop, [Android](https://github.com/element-hq/element-x-android) and [iOS](https://github.com/element-hq/element-x-ios). These platforms need to have feature parity and design consistency. For some features, supporting all platforms is a must have, in some cases exceptions can be made to have it on one platform only. +- To make sure your idea fits both from a design/solution and use case perspective, please open a new issue (or find an existing issue) in [element-meta](https://github.com/element-hq/element-meta/issues) repository describing the use case and how you plan to tackle it. Do not just describe what feature is missing, explain why the users need it with a couple of real life examples from the field. + - In case of an existing issue, please comment that you're planning to contribute. If you create a new issue, please specify that in the issue. In such a case we will try to review the issue ASAP and provide you with initial feedback so you can be confident if and at which conditions your contributions will be accepted. + +Once we know that you want to contribute and have confirmed that the new feature is overall aligned with the product direction, the designers of the core team will help you with the designs and any other type of guidance when it comes to the user experience. We will try to unblock you as quickly as we can, but it may not be instant. Having a clear understanding of the use case and the impact of the feature will help us with the prioritization and faster responses. + +Only once all of the above is met should you open a PR with your proposed changes. + ### Changelogs There's no need to manually add Changelog entries: we use information in the diff --git a/apps/desktop/.node-version b/apps/desktop/.node-version index 8e35034890..5bf4400f22 100644 --- a/apps/desktop/.node-version +++ b/apps/desktop/.node-version @@ -1 +1 @@ -24.14.1 +24.15.0 diff --git a/apps/desktop/dockerbuild/Dockerfile b/apps/desktop/dockerbuild/Dockerfile index 0acea0e60d..f9521745ba 100644 --- a/apps/desktop/dockerbuild/Dockerfile +++ b/apps/desktop/dockerbuild/Dockerfile @@ -1,7 +1,7 @@ # Docker image to facilitate building Element Desktop's native bits using a glibc version (2.31) # with broader compatibility, down to Debian bullseye & Ubuntu focal. -FROM rust:bullseye@sha256:bc19574c121fe10c1bc68fc2b1ea9b420d87d047a0c50fb1622b282199700cee +FROM rust:bullseye@sha256:949b0903defbfc4e374dc85f947b153859e9ee0104e425cd9a74d94474a9a335 ENV DEBIAN_FRONTEND=noninteractive diff --git a/apps/desktop/hak/tsconfig.json b/apps/desktop/hak/tsconfig.json index f547523e48..b762dda71a 100644 --- a/apps/desktop/hak/tsconfig.json +++ b/apps/desktop/hak/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { - "moduleResolution": "node", + "moduleResolution": "node16", + "module": "Node16", "esModuleInterop": true, "target": "es2022", "sourceMap": false, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index eb63cd55ee..158bb1a284 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -28,7 +28,7 @@ "mkdirs": "mkdirp packages deploys", "fetch": "pnpm run mkdirs && node scripts/fetch-package.ts", "asar-webapp": "asar p webapp webapp.asar", - "start": "pnpm run build:ts && pnpm run build:res && electron .", + "start": "nx start", "lint": "pnpm lint:types && pnpm lint:js", "lint:js": "eslint --max-warnings 0 src hak playwright scripts", "lint:js-fix": "eslint --fix --max-warnings 0 src hak playwright scripts && prettier --log-level=warn --write .", @@ -39,22 +39,19 @@ "lint:types:hak": "tsc --noEmit -p hak/tsconfig.json", "build:native": "pnpm run hak", "build:native:universal": "pnpm run hak --target x86_64-apple-darwin fetchandbuild && pnpm run hak --target aarch64-apple-darwin fetchandbuild && pnpm run hak --target x86_64-apple-darwin --target aarch64-apple-darwin copyandlink", - "build:32": "pnpm run build:ts && pnpm run build:res && electron-builder --ia32", - "build:64": "pnpm run build:ts && pnpm run build:res && electron-builder --x64", - "build:universal": "pnpm run build:ts && pnpm run build:res && electron-builder --universal", - "build": "pnpm run build:ts && pnpm run build:res && electron-builder", - "build:ts": "tsc", - "build:res": "node scripts/copy-res.ts", + "build:32": "nx build --ia32", + "build:64": "nx build --x64", + "build:universal": "nx build --universal", + "build": "nx build --", "docker:setup": "docker build --platform linux/amd64 -t element-desktop-dockerbuild -f dockerbuild/Dockerfile .", "docker:build:native": "scripts/in-docker.sh pnpm run hak", "docker:build": "scripts/in-docker.sh pnpm run build", "docker:install": "scripts/in-docker.sh pnpm install", "clean": "rimraf webapp.asar dist packages deploys lib", "hak": "node scripts/hak/index.ts", - "test": "playwright test", - "test:open": "pnpm test --ui", - "test:screenshots:build": "docker build playwright -t element-desktop-playwright --platform linux/amd64", - "test:screenshots:run": "docker run --rm --network host -v $(pwd):/work/element-desktop -v element-desktop-playwright:/work/element-desktop/node_modules -v /var/run/docker.sock:/var/run/docker.sock --platform linux/amd64 -it element-desktop-playwright", + "test:playwright": "nx test:playwright --", + "test:playwright:open": "nx test:playwright -- --ui", + "test:playwright:screenshots": "nx test:playwright:screenshots --", "sane-postinstall": "electron-builder install-app-deps" }, "dependencies": { @@ -65,13 +62,14 @@ "electron-window-state": "^5.0.3", "minimist": "^1.2.6", "png-to-ico": "^3.0.0", - "uuid": "^13.0.0" + "uuid": "^14.0.0" }, "devDependencies": { "@babel/core": "^7.18.10", "@babel/preset-env": "^7.18.10", "@babel/preset-typescript": "^7.18.6", - "@electron/asar": "4.1.2", + "@electron/asar": "4.2.0", + "@electron/fuses": "^2.1.1", "@playwright/test": "catalog:", "@stylistic/eslint-plugin": "^5.0.0", "@types/auto-launch": "^5.0.1", @@ -81,12 +79,12 @@ "@types/pacote": "^11.1.1", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", - "app-builder-lib": "26.8.2", + "app-builder-lib": "26.9.0", "chokidar": "^5.0.0", "detect-libc": "^2.0.0", - "electron": "41.1.0", - "electron-builder": "26.8.2", - "electron-builder-squirrel-windows": "26.8.2", + "electron": "41.2.2", + "electron-builder": "26.9.0", + "electron-builder-squirrel-windows": "26.9.0", "electron-devtools-installer": "^4.0.0", "eslint": "^8.26.0", "eslint-config-google": "^0.14.0", @@ -102,10 +100,13 @@ "prettier": "^3.0.0", "rimraf": "^6.0.0", "tar": "^7.5.8", - "typescript": "5.9.3" + "typescript": "6.0.3" }, "hakDependencies": { - "matrix-seshat": "4.0.1" + "matrix-seshat": "4.2.0" }, - "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319" + "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319", + "nx": { + "includedScripts": [] + } } diff --git a/apps/desktop/playwright/Dockerfile b/apps/desktop/playwright/Dockerfile index dabf89dd46..212eeb3438 100644 --- a/apps/desktop/playwright/Dockerfile +++ b/apps/desktop/playwright/Dockerfile @@ -1,13 +1,19 @@ FROM mcr.microsoft.com/playwright:v1.59.1-jammy@sha256:8a0360d39d1973be506dd59002904a774f6d697d4946c94063b3fd006461c8ff -WORKDIR /work/element-desktop +WORKDIR /work -RUN apt-get update && apt-get -y install xvfb dbus-x11 && apt-get purge -y --auto-remove && rm -rf /var/lib/apt/lists/* +RUN apt-get update && \ + apt-get -y install xvfb dbus-x11 && \ + apt-get purge -y --auto-remove && \ + rm -rf /var/lib/apt/lists/* && \ + corepack enable -# Create node_modules & dist dirs so that the volumes have the correct permissions -RUN mkdir node_modules dist && chown 1000:1000 node_modules dist +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +ENV GITHUB_ACTIONS=1 +ENV DEBUG=pw:browser +# switch to node user USER 1000:1000 -COPY docker-entrypoint.sh /opt/docker-entrypoint.sh +COPY apps/desktop/playwright/docker-entrypoint.sh /opt/docker-entrypoint.sh ENTRYPOINT ["bash", "/opt/docker-entrypoint.sh"] diff --git a/apps/desktop/playwright/docker-entrypoint.sh b/apps/desktop/playwright/docker-entrypoint.sh index 68deb4af9a..fd846cb2fa 100644 --- a/apps/desktop/playwright/docker-entrypoint.sh +++ b/apps/desktop/playwright/docker-entrypoint.sh @@ -8,11 +8,5 @@ sleep 2 export DISPLAY=:99 -pnpm install --frozen-lockfile -pnpm build -l --dir - PLAYWRIGHT_HTML_OPEN=never ELEMENT_DESKTOP_EXECUTABLE="./dist/linux-unpacked/element-desktop" \ - npx playwright test --update-snapshots --reporter line,html "$1" - -# Clean up -rm -R core qemu_* || exit 0 + exec pnpm -C apps/desktop exec playwright test --update-snapshots --reporter line,html "$1" diff --git a/apps/desktop/playwright/element-desktop-test.ts b/apps/desktop/playwright/element-desktop-test.ts index 743be8da6f..b2fa3c983e 100644 --- a/apps/desktop/playwright/element-desktop-test.ts +++ b/apps/desktop/playwright/element-desktop-test.ts @@ -17,14 +17,14 @@ import { PassThrough } from "node:stream"; * A PassThrough stream that captures all data written to it. */ class CapturedPassThrough extends PassThrough { - private _chunks = []; + private _chunks: any[] = []; public constructor() { super(); super.on("data", this.onData); } - private onData = (chunk): void => { + private onData = (chunk: any): void => { this._chunks.push(chunk); }; @@ -69,7 +69,13 @@ export const test = base.extend({ const args = ["--profile-dir", tmpDir, ...extraArgs]; if (process.env.GITHUB_ACTIONS) { + args.push("--disable-gpu"); + if (process.platform === "linux") { + if (process.getuid() === 0) { + args.push("--no-sandbox"); + } + // GitHub Actions hosted runner lacks dbus and a compatible keyring, so we need to force plaintext storage args.push("--storage-mode", "force-plaintext"); } else if (process.platform === "darwin") { diff --git a/apps/desktop/playwright/snapshots/launch/launch.spec.ts/App-launch-should-launch-and-render-the-welcome-view-successfully-1-linux.png b/apps/desktop/playwright/snapshots/launch/launch.spec.ts/App-launch-should-launch-and-render-the-welcome-view-successfully-1-linux.png index 7bcf260d52..baec548402 100644 Binary files a/apps/desktop/playwright/snapshots/launch/launch.spec.ts/App-launch-should-launch-and-render-the-welcome-view-successfully-1-linux.png and b/apps/desktop/playwright/snapshots/launch/launch.spec.ts/App-launch-should-launch-and-render-the-welcome-view-successfully-1-linux.png differ diff --git a/apps/desktop/playwright/tsconfig.json b/apps/desktop/playwright/tsconfig.json index 4c9756442b..931ff18075 100644 --- a/apps/desktop/playwright/tsconfig.json +++ b/apps/desktop/playwright/tsconfig.json @@ -1,11 +1,12 @@ { "compilerOptions": { "resolveJsonModule": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "esModuleInterop": true, "target": "es2022", - "module": "es2022", - "lib": ["es2022", "dom"], + "module": "ESNext", + "lib": ["es2024", "dom", "dom.iterable"], + "strictNullChecks": false, "types": ["node"] }, "include": ["**/*.ts"] diff --git a/apps/desktop/project.json b/apps/desktop/project.json index cb43a99d61..ae1bd1453e 100644 --- a/apps/desktop/project.json +++ b/apps/desktop/project.json @@ -19,6 +19,65 @@ "tags": ["type=ref,event=branch"] } } + }, + "build:ts": { + "cache": true, + "command": "tsc", + "inputs": ["src", "{projectRoot}/tsconfig.json"], + "outputs": ["{projectRoot}/lib/*.js", "{projectRoot}/lib/*.d.ts"], + "options": { "cwd": "apps/desktop" } + }, + "build:res": { + "cache": true, + "command": "node scripts/copy-res.ts", + "inputs": ["{projectRoot}/i18n"], + "outputs": ["{projectRoot}/lib/i18n"], + "options": { "cwd": "apps/desktop" } + }, + "build": { + "cache": true, + "command": "pnpm exec electron-builder", + "inputs": [ + "src", + "{projectRoot}/.hak/hakModules", + "{projectRoot}/electron-builder.json", + "{projectRoot}/webapp.asar" + ], + "outputs": ["{projectRoot}/dist"], + "options": { "cwd": "apps/desktop" }, + "dependsOn": ["build:*"] + }, + "start": { + "command": "electron .", + "options": { "cwd": "apps/desktop" }, + "dependsOn": ["build:*"] + }, + "test:playwright": { + "command": "playwright test", + "options": { "cwd": "apps/desktop" } + }, + "test:playwright:screenshots:build-app": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "pnpm run build -l --x64 --dir --publish=never", + "pnpm exec electron-fuses write --app ./dist/linux-unpacked/element-desktop EnableNodeCliInspectArguments=on" + ], + "parallel": false, + "cwd": "apps/desktop" + }, + "dependsOn": ["build:*"] + }, + "test:playwright:screenshots:build-docker": { + "cache": true, + "command": "docker build -f playwright/Dockerfile -t element-desktop-playwright --platform linux/amd64 ../..", + "inputs": ["{projectRoot}/playwright/Dockerfile", "{projectRoot}/playwright/docker-entrypoint.sh"], + "options": { "cwd": "apps/desktop" } + }, + "test:playwright:screenshots": { + "command": "docker run --rm --network host -v $(pwd)/../../:/work/ --platform linux/amd64 -it element-desktop-playwright", + "options": { "cwd": "apps/desktop" }, + "dependsOn": ["test:playwright:screenshots:*"] } } } diff --git a/apps/desktop/src/i18n/strings/zh_Hans.json b/apps/desktop/src/i18n/strings/zh_Hans.json index 448412456d..d9b6be5832 100644 --- a/apps/desktop/src/i18n/strings/zh_Hans.json +++ b/apps/desktop/src/i18n/strings/zh_Hans.json @@ -22,7 +22,9 @@ "about": "关于", "brand_help": "%(brand)s帮助", "help": "帮助", - "preferences": "偏好" + "no": "否", + "preferences": "偏好", + "yes": "是" }, "confirm_quit": "你确定要退出吗?", "edit_menu": { @@ -30,9 +32,20 @@ "speech_start_speaking": "开始讲话", "speech_stop_speaking": "停止讲话" }, + "eol": { + "no_more_updates": "你正在使用的 macOS 版本不受支持。请升级以获取 %(brand)s 更新。", + "title": "不受支持的系统", + "warning": "你正在使用的 macOS 版本不受支持。请升级系统以确保 %(brand)s 能保持运行。" + }, "file_menu": { "label": "文件" }, + "icon_overlay": { + "description_error": "错误", + "description_notifications": { + "other": "你有 %(count)s 条未读通知。" + } + }, "menu": { "hide": "隐藏", "hide_others": "隐藏其他", @@ -49,6 +62,21 @@ "save_image_as_error_description": "图片保存失败", "save_image_as_error_title": "图片保存失败" }, + "store": { + "error": { + "backend_changed": "清除数据并重新加载?", + "backend_changed_detail": "无法从系统密钥环访问密钥,该密钥似乎已被更改。", + "backend_changed_title": "数据库加载失败", + "backend_no_encryption": "你的系统支持密钥环,但加密功能不可用。", + "backend_no_encryption_detail": "Electron 检测到你的密钥环 %(backend)s 的加密不可用。请确保已安装该密钥环。若已安装请重启设备后重试。也可选择允许 %(brand)s 使用弱加密方式。", + "backend_no_encryption_title": "不支持加密", + "unsupported_keyring": "你的系统存在不受支持的密钥环,这意味着无法打开数据库。", + "unsupported_keyring_detail": "Electron 的密钥环检测未找到受支持的后端。你可以尝试通过命令行参数启动 %(brand)s 来手动配置后端,此操作仅需执行一次。详情请参阅:%(link)s。", + "unsupported_keyring_title": "不受支持的系统", + "unsupported_keyring_use_basic_text": "使用弱加密", + "unsupported_keyring_use_plaintext": "不使用加密" + } + }, "view_menu": { "actual_size": "实际大小", "toggle_developer_tools": "切换开发者工具", diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index ca8d04950d..2cc7e22fdb 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -1,8 +1,8 @@ -# syntax=docker.io/docker/dockerfile:1.22-labs@sha256:4c116b618ed48404d579b5467127b20986f2a6b29e4b9be2fee841f632db6a86 +# syntax=docker.io/docker/dockerfile:1.23-labs@sha256:7eca9451d94f9b8ad22e44988b92d595d3e4d65163794237949a8c3413fbed5d # Context must be the root of the monorepo # Builder -FROM --platform=$BUILDPLATFORM node:24-bullseye@sha256:27e462f5db2402700867dfa8ec35e3a68b127fdf61b505db0dd6ab98c38284bb AS builder +FROM --platform=$BUILDPLATFORM node:24-bullseye@sha256:d2059a9c157c9f70739736979fa3635008bf3ca74560b30930dc181228bc427f AS builder # Support custom branch of the js-sdk. This also helps us build images of element-web develop. ARG USE_CUSTOM_SDKS=false @@ -25,7 +25,7 @@ RUN --mount=type=bind,source=.git,target=/src/.git /src/scripts/docker-package.s RUN cp /src/apps/web/config.sample.json /src/apps/web/webapp/config.json # App -FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:b5831ee7f7aa827cbae87df4a30a642f62c747d8525f5674365389f3adab278d +FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:360465db60105a4cbf5215cd9e5a2ba40ef956978dd94f99707e9674050e38ea # Need root user to install packages & manipulate the usr directory USER root diff --git a/apps/web/Dockerfile.dockerignore b/apps/web/Dockerfile.dockerignore index c1af93fc7e..f43268b142 100644 --- a/apps/web/Dockerfile.dockerignore +++ b/apps/web/Dockerfile.dockerignore @@ -23,6 +23,8 @@ apps/web/webpack-stats.json apps/web/playwright/ apps/web/webapp/ apps/web/debian/ +apps/desktop +!apps/desktop/package.json packages/shared-components/__vis__/ packages/shared-components/storybook-static/ diff --git a/apps/web/package.json b/apps/web/package.json index d724f50fd4..06617d8f62 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -72,7 +72,7 @@ "glob-to-regexp": "^0.4.1", "highlight.js": "^11.3.1", "html-entities": "^2.0.0", - "html-react-parser": "^5.2.2", + "html-react-parser": "^6.0.0", "is-ip": "^5.0.0", "js-xxhash": "^5.0.0", "jsrsasign": "^11.0.0", @@ -89,7 +89,7 @@ "opus-recorder": "^8.0.3", "pako": "^2.0.3", "png-chunks-extract": "^1.0.0", - "posthog-js": "1.364.7", + "posthog-js": "1.369.3", "qrcode": "1.5.4", "re-resizable": "6.11.2", "react": "catalog:", @@ -101,10 +101,9 @@ "react-transition-group": "^4.4.1", "rfc4648": "^1.4.0", "sanitize-filename": "^1.6.3", - "sanitize-html": "2.17.2", + "sanitize-html": "2.17.3", "tar-js": "^0.3.0", "ua-parser-js": "1.0.40", - "uuid": "^13.0.0", "what-input": "^5.2.10" }, "devDependencies": { @@ -125,8 +124,8 @@ "@babel/preset-env": "^7.12.11", "@babel/preset-react": "^7.12.10", "@babel/preset-typescript": "^7.12.7", - "@casualbot/jest-sonar-reporter": "2.5.1", - "@element-hq/element-call-embedded": "0.18.0", + "@casualbot/jest-sonar-reporter": "2.6.0", + "@element-hq/element-call-embedded": "0.19.2", "@element-hq/element-web-playwright-common": "workspace:*", "@fetch-mock/jest": "^0.2.20", "@jest/globals": "^30.2.0", @@ -205,17 +204,17 @@ "mini-css-extract-plugin": "2.10.2", "modernizr": "^3.12.0", "playwright-core": "catalog:", - "postcss": "8.5.8", + "postcss": "8.5.10", "postcss-easings": "4.0.0", "postcss-hexrgba": "2.1.0", "postcss-import": "16.1.1", "postcss-loader": "8.2.1", "postcss-mixins": "12.1.2", "postcss-nested": "7.0.2", - "postcss-preset-env": "11.2.0", + "postcss-preset-env": "11.2.1", "postcss-scss": "4.0.9", "postcss-simple-vars": "7.0.1", - "prettier": "3.8.1", + "prettier": "3.8.3", "process": "^0.11.10", "raw-loader": "^4.0.2", "semver": "^7.5.2", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index f6d15f4e79..6120557148 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -91,7 +91,7 @@ export default defineConfig<{}, WorkerOptions>({ trace: "on-first-retry", }, webServer: { - command: process.env.CI ? "npx serve -p 8080 -L ./webapp" : "pnpm start", + command: process.env.CI ? "npx serve -p 8080 -L ./webapp" : "nx --outputStyle stream start", url: `${baseURL}/config.json`, reuseExistingServer: true, timeout: (process.env.CI ? 30 : 120) * 1000, diff --git a/apps/web/playwright/e2e/accessibility/keyboard-navigation.spec.ts b/apps/web/playwright/e2e/accessibility/keyboard-navigation.spec.ts index 8ba6cc3a92..a43a4e07b4 100644 --- a/apps/web/playwright/e2e/accessibility/keyboard-navigation.spec.ts +++ b/apps/web/playwright/e2e/accessibility/keyboard-navigation.spec.ts @@ -15,6 +15,8 @@ test.describe("Landmark navigation tests", () => { }); test("without any rooms", async ({ page, homeserver, app, user }) => { + await app.closeVerifyToast(); + // sometimes the space button doesn't appear right away await expect(page.locator(".mx_SpaceButton_active")).toBeVisible(); @@ -118,6 +120,7 @@ test.describe("Landmark navigation tests", () => { }, ); + await app.closeVerifyToast(); await app.viewRoomByName("Bob"); // confirm the room was loaded await expect(page.getByText("Bob joined the room")).toBeVisible(); diff --git a/apps/web/playwright/e2e/app-loading/guest-registration.spec.ts b/apps/web/playwright/e2e/app-loading/guest-registration.spec.ts index 960b6a6692..d5679bc017 100644 --- a/apps/web/playwright/e2e/app-loading/guest-registration.spec.ts +++ b/apps/web/playwright/e2e/app-loading/guest-registration.spec.ts @@ -20,7 +20,7 @@ test.use({ test("Shows the welcome page by default", async ({ page }) => { await page.goto("/"); - await expect(page.getByRole("heading", { name: "Welcome to Element!" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Be in your element" })).toBeVisible(); await expect(page.getByRole("link", { name: "Sign in" })).toBeVisible(); }); diff --git a/apps/web/playwright/e2e/audio-player/audio-player.spec.ts b/apps/web/playwright/e2e/audio-player/audio-player.spec.ts index f042b07993..7aac1a6fd9 100644 --- a/apps/web/playwright/e2e/audio-player/audio-player.spec.ts +++ b/apps/web/playwright/e2e/audio-player/audio-player.spec.ts @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. */ import type { Locator, Page } from "@playwright/test"; -import { test, expect } from "../../element-web-test"; +import { test, expect, type ExtendedToMatchScreenshotOptions } from "../../element-web-test"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import { Layout } from "../../../src/settings/enums/Layout"; import { type ElementAppPage } from "../../pages/ElementAppPage"; @@ -94,7 +94,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { // Assert that rendering of the player settled and the play button is visible before taking a snapshot await checkPlayerVisibility(ircTile); - const screenshotOptions = { + const screenshotOptions: ExtendedToMatchScreenshotOptions = { css: ` /* The timestamp is of inconsistent width depending on the time the test runs at */ .mx_MessageTimestamp { @@ -120,7 +120,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { }; // Take a snapshot of mx_EventTile_last on IRC layout - screenshotOptions.clip = await page.locator(".mx_EventTile_last").boundingBox(); + screenshotOptions.clip = (await page.locator(".mx_EventTile_last").boundingBox()) ?? undefined; await scrollToBottomOfTimeline(page); await expect(page).toMatchScreenshot(`${detail.replaceAll(" ", "-")}-irc-layout.png`, screenshotOptions); @@ -129,7 +129,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { const groupTile = page.locator(".mx_EventTile_last[data-layout='group']"); await groupTile.locator(".mx_MessageTimestamp").click(); await checkPlayerVisibility(groupTile); - screenshotOptions.clip = await page.locator(".mx_EventTile_last").boundingBox(); + screenshotOptions.clip = (await page.locator(".mx_EventTile_last").boundingBox()) ?? undefined; await scrollToBottomOfTimeline(page); await expect(page).toMatchScreenshot(`${detail.replaceAll(" ", "-")}-group-layout.png`, screenshotOptions); @@ -138,12 +138,13 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { const bubbleTile = page.locator(".mx_EventTile_last[data-layout='bubble']"); await bubbleTile.locator(".mx_MessageTimestamp").click(); await checkPlayerVisibility(bubbleTile); - screenshotOptions.clip = await page.locator(".mx_EventTile_last").boundingBox(); + screenshotOptions.clip = (await page.locator(".mx_EventTile_last").boundingBox()) ?? undefined; await scrollToBottomOfTimeline(page); await expect(page).toMatchScreenshot(`${detail.replaceAll(" ", "-")}-bubble-layout.png`, screenshotOptions); }; test.beforeEach(async ({ page, app, user }) => { + await app.closeVerifyToast(); await app.client.createRoom({ name: "Test Room" }); await app.viewRoomByName("Test Room"); diff --git a/apps/web/playwright/e2e/crypto/crypto.spec.ts b/apps/web/playwright/e2e/crypto/crypto.spec.ts index d03fa1454e..5d9231b67b 100644 --- a/apps/web/playwright/e2e/crypto/crypto.spec.ts +++ b/apps/web/playwright/e2e/crypto/crypto.spec.ts @@ -27,6 +27,9 @@ const startDMWithBob = async (page: Page, bob: Bot) => { await page.getByRole("option", { name: bob.credentials.displayName }).click(); await expect(page.getByTestId("invite-dialog-input-wrapper").getByText("Bob")).toBeVisible(); await page.getByRole("button", { name: "Go" }).click(); + + await expect(page.getByRole("heading", { name: "Start a chat with this new contact?" })).toBeVisible(); + await page.getByRole("button", { name: "Continue" }).click(); }; const testMessages = async (page: Page, bob: Bot, bobRoomId: string) => { @@ -44,7 +47,7 @@ const bobJoin = async (page: Page, bob: Bot) => { const bobRooms = cli.getRooms(); if (!bobRooms.length) { await new Promise((resolve) => { - const onMembership = (_event) => { + const onMembership = () => { cli.off(window.matrixcs.RoomMemberEvent.Membership, onMembership); resolve(); }; @@ -169,6 +172,7 @@ test.describe("Cryptography", function () { "creating a DM should work, being e2e-encrypted / user verification", { tag: "@screenshot" }, async ({ page, app, bot: bob, user: aliceCredentials }) => { + await app.closeVerifyToast(); await app.client.bootstrapCrossSigning(aliceCredentials); await startDMWithBob(page, bob); // send first message diff --git a/apps/web/playwright/e2e/crypto/decryption-failure-messages.spec.ts b/apps/web/playwright/e2e/crypto/decryption-failure-messages.spec.ts index 728b3bf620..1dc04ee905 100644 --- a/apps/web/playwright/e2e/crypto/decryption-failure-messages.spec.ts +++ b/apps/web/playwright/e2e/crypto/decryption-failure-messages.spec.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import type { EmittedEvents, Preset } from "matrix-js-sdk/src/matrix"; +import type { Preset, RoomMemberEvent, RoomStateEvent } from "matrix-js-sdk/src/matrix"; import { expect, test } from "../../element-web-test"; import { createRoom, @@ -122,7 +122,7 @@ test.describe("Cryptography", function () { const roomId = await bob.evaluate( async (client, { alice }) => { const encryptionStatePromise = new Promise((resolve) => { - client.on("RoomState.events" as EmittedEvents, (event, _state, _lastStateEvent) => { + client.on("RoomState.events" as RoomStateEvent.Events, (event, _state, _lastStateEvent) => { if (event.getType() === "m.room.encryption") { resolve(); } @@ -253,11 +253,14 @@ test.describe("Cryptography", function () { // invite Alice const inviteAlicePromise = new Promise((resolve) => { - client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => { - if (member.userId === alice.userId && member.membership === "invite") { - resolve(); - } - }); + client.on( + "RoomMember.membership" as RoomMemberEvent.Membership, + (_event, member, _oldMembership?) => { + if (member.userId === alice.userId && member.membership === "invite") { + resolve(); + } + }, + ); }); await client.invite(roomId, alice.userId); // wait for the invite to come back so that we encrypt to Alice @@ -271,11 +274,14 @@ test.describe("Cryptography", function () { // kick Alice const kickAlicePromise = new Promise((resolve) => { - client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => { - if (member.userId === alice.userId && member.membership === "leave") { - resolve(); - } - }); + client.on( + "RoomMember.membership" as RoomMemberEvent.Membership, + (_event, member, _oldMembership?) => { + if (member.userId === alice.userId && member.membership === "leave") { + resolve(); + } + }, + ); }); await client.kick(roomId, alice.userId); await kickAlicePromise; diff --git a/apps/web/playwright/e2e/crypto/dehydration.spec.ts b/apps/web/playwright/e2e/crypto/dehydration.spec.ts index 2707e2d45d..ccb98e89df 100644 --- a/apps/web/playwright/e2e/crypto/dehydration.spec.ts +++ b/apps/web/playwright/e2e/crypto/dehydration.spec.ts @@ -166,13 +166,9 @@ async function getDehydratedDeviceIds(client: Client): Promise { return await client.evaluate(async (client) => { const userId = client.getUserId(); const devices = await client.getCrypto().getUserDeviceInfo([userId]); - return Array.from( - devices - .get(userId) - .values() - .filter((d) => d.dehydrated) - .map((d) => d.deviceId), - ); + return Array.from(devices.get(userId).values()) + .filter((d) => d.dehydrated) + .map((d) => d.deviceId); }); } diff --git a/apps/web/playwright/e2e/crypto/device-verification.spec.ts b/apps/web/playwright/e2e/crypto/device-verification.spec.ts index 07fa4ed9d8..7fd17177d3 100644 --- a/apps/web/playwright/e2e/crypto/device-verification.spec.ts +++ b/apps/web/playwright/e2e/crypto/device-verification.spec.ts @@ -124,6 +124,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { await infoDialog.getByRole("button", { name: "Got it" }).click(); // There should be no toast (other than the notifications one) + await toasts.rejectToast("Verify this device"); await toasts.rejectToast("Notifications"); await toasts.assertNoToasts(); diff --git a/apps/web/playwright/e2e/crypto/history-sharing.spec.ts b/apps/web/playwright/e2e/crypto/history-sharing.spec.ts index 3974ba79c5..f2017e9467 100644 --- a/apps/web/playwright/e2e/crypto/history-sharing.spec.ts +++ b/apps/web/playwright/e2e/crypto/history-sharing.spec.ts @@ -13,7 +13,6 @@ import { createRoom, sendMessageInCurrentRoom } from "./utils"; test.use({ displayName: "Alice", - labsFlags: ["feature_share_history_on_invite"], }); /** Tests for MSC4268: encrypted history sharing */ @@ -29,12 +28,16 @@ test.describe("History sharing", function () { // we then invite Bob, and ensure Bob can see the content. await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials); + await aliceElementApp.closeKeyStorageToast(); // Register a second user, and open it in a second instance of the app const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "Bob"); const bobPage = await createNewInstance(browser, bobCredentials, {}, labsFlags); const bobElementApp = new ElementAppPage(bobPage); await bobElementApp.client.bootstrapCrossSigning(bobCredentials); + await bobElementApp.closeKeyStorageToast(); + + await aliceElementApp.closeNotificationToast(); // Create the room and send a message await createRoom(alicePage, "TestRoom", true); @@ -49,7 +52,7 @@ test.describe("History sharing", function () { await sendMessageInCurrentRoom(alicePage, "A message from Alice"); // Send the invite to Bob - await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); + await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true }); // Bob accepts the invite await bobPage.getByRole("option", { name: "TestRoom" }).click(); @@ -85,6 +88,7 @@ test.describe("History sharing", function () { // 5. Charlie can't see the message. await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials); + await aliceElementApp.closeKeyStorageToast(); await createRoom(alicePage, "TestRoom", true); // Register a second user, and open it in a second instance of the app @@ -92,6 +96,7 @@ test.describe("History sharing", function () { const bobPage = await createNewInstance(browser, bobCredentials, {}, labsFlags); const bobElementApp = new ElementAppPage(bobPage); await bobElementApp.client.bootstrapCrossSigning(bobCredentials); + await bobElementApp.closeKeyStorageToast(); // ... and a third const charlieCredentials = await homeserver.registerUser( @@ -102,10 +107,11 @@ test.describe("History sharing", function () { const charliePage = await createNewInstance(browser, charlieCredentials, {}, labsFlags); const charlieElementApp = new ElementAppPage(charliePage); await charlieElementApp.client.bootstrapCrossSigning(charlieCredentials); + await charlieElementApp.closeKeyStorageToast(); // Alice invites Bob, and Bob accepts const roomId = await aliceElementApp.getCurrentRoomIdFromUrl(); - await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); + await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true }); await bobPage.getByRole("option", { name: "TestRoom" }).click(); await bobPage.getByRole("button", { name: "Accept" }).click(); @@ -143,7 +149,7 @@ test.describe("History sharing", function () { await sendMessageInCurrentRoom(bobPage, "Message3: 'shared' visibility, but Bob thinks it is still 'joined'"); // Alice now invites Charlie - await aliceElementApp.inviteUserToCurrentRoom(charlieCredentials.userId); + await aliceElementApp.inviteUserToCurrentRoom(charlieCredentials.userId, { confirmUnknownUser: true }); await charliePage.getByRole("option", { name: "TestRoom" }).click(); await charliePage.getByRole("button", { name: "Accept" }).click(); diff --git a/apps/web/playwright/e2e/crypto/toasts.spec.ts b/apps/web/playwright/e2e/crypto/toasts.spec.ts index 9ce1b8a5ae..72451a03da 100644 --- a/apps/web/playwright/e2e/crypto/toasts.spec.ts +++ b/apps/web/playwright/e2e/crypto/toasts.spec.ts @@ -31,15 +31,6 @@ test.describe("Key storage out of sync toast", () => { await logIntoElementAndVerify(page, credentials, recoveryKey.encodedPrivateKey); await deleteCachedSecrets(page); - - // We won't be prompted for crypto setup unless we have an e2e room, so make one - await page - .getByRole("navigation", { name: "Room list" }) - .getByRole("button", { name: "New conversation" }) - .click(); - await page.getByRole("menuitem", { name: "New room" }).click(); - await page.getByRole("textbox", { name: "Name" }).fill("Test room"); - await page.getByRole("button", { name: "Create room" }).click(); }); test("should prompt for recovery key if 'enter recovery key' pressed", { tag: "@screenshot" }, async ({ page }) => { diff --git a/apps/web/playwright/e2e/crypto/utils.ts b/apps/web/playwright/e2e/crypto/utils.ts index 56378358ea..479ae12a78 100644 --- a/apps/web/playwright/e2e/crypto/utils.ts +++ b/apps/web/playwright/e2e/crypto/utils.ts @@ -579,8 +579,8 @@ export async function deleteCachedSecrets(page: Page) { await page.evaluate(async () => { const removeCachedSecrets = new Promise((resolve) => { const request = window.indexedDB.open("matrix-js-sdk::matrix-sdk-crypto"); - request.onsuccess = (event: Event & { target: { result: IDBDatabase } }) => { - const db = event.target.result; + request.onsuccess = function (this: IDBRequest) { + const db = this.result as IDBDatabase; const request = db.transaction("core", "readwrite").objectStore("core").delete("private_identity"); request.onsuccess = () => { db.close(); diff --git a/apps/web/playwright/e2e/devtools/lowbandwidth.spec.ts b/apps/web/playwright/e2e/devtools/lowbandwidth.spec.ts index d24ac69b94..8d5022623d 100644 --- a/apps/web/playwright/e2e/devtools/lowbandwidth.spec.ts +++ b/apps/web/playwright/e2e/devtools/lowbandwidth.spec.ts @@ -19,6 +19,7 @@ test.describe("Devtools", () => { const profileSettings = userSettings.locator(".mx_UserProfileSettings"); await profileSettings.getByAltText("Upload").setInputFiles(getSampleFilePath("riot.png")); await app.closeDialog(); + await app.closeVerifyToast(); // Create an initial room. const createRoomDialog = await app.openCreateRoomDialog(); diff --git a/apps/web/playwright/e2e/editing/editing.spec.ts b/apps/web/playwright/e2e/editing/editing.spec.ts index 3754bcfb62..9b0efecb26 100644 --- a/apps/web/playwright/e2e/editing/editing.spec.ts +++ b/apps/web/playwright/e2e/editing/editing.spec.ts @@ -50,7 +50,7 @@ test.describe("Editing", () => { const eventTile = page.locator(".mx_EventTile", { hasText: edited }); await expect(eventTile).toBeVisible(); // Click to display the message edit history dialog - await eventTile.getByText("(edited)").click(); + await eventTile.getByRole("button", { name: /Edited at .*? Click to view edits\./ }).click(); }; const clickButtonViewSource = async (locator: Locator) => { @@ -89,7 +89,7 @@ test.describe("Editing", () => { await editLastMessage(page, "Massage"); // Assert that the edit label is visible - await expect(page.locator(".mx_EventTile_edited")).toBeVisible(); + await expect(page.getByRole("button", { name: /Edited at .*? Click to view edits\./ })).toBeVisible(); await clickEditedMessage(page, "Massage"); @@ -213,7 +213,7 @@ test.describe("Editing", () => { await editLastMessage(page, "Massage"); // Assert that the edit label is visible - await expect(page.locator(".mx_EventTile_edited")).toBeVisible(); + await expect(page.getByRole("button", { name: /Edited at .*? Click to view edits\./ })).toBeVisible(); await clickEditedMessage(page, "Massage"); @@ -369,6 +369,6 @@ test.describe("Editing", () => { // nevertheless, the event should be updated await expect(messageTile.locator(".mx_EventTile_body")).toHaveText("Edited body"); - await expect(messageTile.locator(".mx_EventTile_edited")).toBeVisible(); + await expect(messageTile.getByRole("button", { name: /Edited at .*? Click to view edits\./ })).toBeVisible(); }); }); diff --git a/apps/web/playwright/e2e/invite/invite-dialog.spec.ts b/apps/web/playwright/e2e/invite/invite-dialog.spec.ts index 42588f37c7..811f948f05 100644 --- a/apps/web/playwright/e2e/invite/invite-dialog.spec.ts +++ b/apps/web/playwright/e2e/invite/invite-dialog.spec.ts @@ -9,6 +9,15 @@ Please see LICENSE files in the repository root for full details. import { test, expect } from "../../element-web-test"; +/** + * CSS which will hide the mxid in the user list of the "unknown users" confirmation dialog. This is useful because the + * MXID is not stable and the screenshot tests will otherwise fail. + * + * Ideally RichItem would give us a way to do this that doesn't involve gnarly CSS. + */ +const UNKNOWN_IDENTITY_USERS_DIALOG_HIDE_MXID_CSS = + '[data-testid="userlist"] li > span:nth-last-child(1) { display: none }'; + test.describe("Invite dialog", function () { test.use({ displayName: "Hanako", @@ -62,6 +71,15 @@ test.describe("Invite dialog", function () { // Invite the bot await other.getByRole("button", { name: "Invite" }).click(); + // Expect a confirmation dialog, screenshot, and dismiss + await expect( + page.locator(".mx_Dialog").getByRole("heading", { name: "Invite new contacts to this room?" }), + ).toBeVisible(); + await expect(page.locator(".mx_Dialog")).toMatchScreenshot("confirm-invite-new-contact.png", { + css: UNKNOWN_IDENTITY_USERS_DIALOG_HIDE_MXID_CSS, + }); + await page.locator(".mx_Dialog").getByRole("button", { name: "Invite" }).click(); + // Assert that the invite dialog disappears await expect(page.locator(".mx_InviteDialog_other")).not.toBeVisible(); @@ -73,6 +91,7 @@ test.describe("Invite dialog", function () { "should support inviting a user to Direct Messages", { tag: "@screenshot" }, async ({ page, app, user, bot }) => { + await app.closeVerifyToast(); await page .getByRole("navigation", { name: "Room list" }) .getByRole("button", { name: "New conversation" }) @@ -104,6 +123,15 @@ test.describe("Invite dialog", function () { // Open a direct message UI await other.getByRole("button", { name: "Go" }).click(); + // Expect a confirmation dialog, screenshot, and dismiss + await expect( + page.locator(".mx_Dialog").getByRole("heading", { name: "Start a chat with this new contact?" }), + ).toBeVisible(); + await expect(page.locator(".mx_Dialog")).toMatchScreenshot("confirm-chat-with-new-contact.png", { + css: UNKNOWN_IDENTITY_USERS_DIALOG_HIDE_MXID_CSS, + }); + await page.locator(".mx_Dialog").getByRole("button", { name: "Continue" }).click(); + // Assert that the invite dialog disappears await expect(page.locator(".mx_InviteDialog_other")).not.toBeVisible(); diff --git a/apps/web/playwright/e2e/knock/create-knock-room.spec.ts b/apps/web/playwright/e2e/knock/create-knock-room.spec.ts index 76a944cd4b..1813907160 100644 --- a/apps/web/playwright/e2e/knock/create-knock-room.spec.ts +++ b/apps/web/playwright/e2e/knock/create-knock-room.spec.ts @@ -19,6 +19,8 @@ test.describe("Create Knock Room", () => { }); test("should create a knock room", async ({ page, app, user }) => { + await app.closeVerifyToast(); + const dialog = await app.openCreateRoomDialog(); await dialog.getByRole("textbox", { name: "Name" }).fill("Cybersecurity"); await dialog.getByRole("button", { name: "Room visibility" }).click(); @@ -37,6 +39,8 @@ test.describe("Create Knock Room", () => { }); test("should create a room and change a join rule to knock", async ({ page, app, user }) => { + await app.closeVerifyToast(); + const dialog = await app.openCreateRoomDialog(); await dialog.getByRole("textbox", { name: "Name" }).fill("Cybersecurity"); await dialog.getByRole("button", { name: "Create room" }).click(); @@ -59,6 +63,8 @@ test.describe("Create Knock Room", () => { }); test("should create a public knock room", async ({ page, app, user }) => { + await app.closeVerifyToast(); + const dialog = await app.openCreateRoomDialog(); await dialog.getByRole("textbox", { name: "Name" }).fill("Cybersecurity"); await dialog.getByRole("button", { name: "Room visibility" }).click(); diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-collapse.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-collapse.spec.ts index 2bf8b558dd..73a670ee9f 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-collapse.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-collapse.spec.ts @@ -14,6 +14,7 @@ test.describe("Collapsible Room list", () => { }); test.beforeEach(async ({ page, app, user }) => { + await app.closeVerifyToast(); await app.closeNotificationToast(); for (let i = 0; i < 10; i++) { await app.client.createRoom({ name: `room${i}` }); diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts new file mode 100644 index 0000000000..742b611faf --- /dev/null +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts @@ -0,0 +1,333 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type Page } from "@playwright/test"; + +import { expect, test } from "../../../element-web-test"; +import { getRoomList, getRoomListHeader, getSectionHeader } from "./utils"; + +test.describe("Room list custom sections", () => { + test.use({ + displayName: "Alice", + labsFlags: ["feature_new_room_list", "feature_room_list_sections"], + botCreateOpts: { + displayName: "BotBob", + autoAcceptInvites: true, + }, + }); + + /** + * Create a custom section via the header compose menu and dialog. + * @param page + * @param sectionName The name of the section to create + */ + async function createCustomSection(page: Page, sectionName: string): Promise { + const composeMenu = getRoomListHeader(page).getByRole("button", { name: "New conversation" }); + await composeMenu.click(); + await page.getByRole("menuitem", { name: "New section" }).click(); + + // Fill in the section name in the dialog + const dialog = page.getByRole("dialog", { name: "Create a section" }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("textbox", { name: "Section name" }).fill(sectionName); + await dialog.getByRole("button", { name: "Create section" }).click(); + + // Wait for the dialog to close + await expect(dialog).not.toBeVisible(); + } + + /** + * Asserts a room is nested under a specific section using the treegrid aria-level hierarchy. + * Section header rows sit at aria-level=1; room rows nested within a section sit at aria-level=2. + * Verifies that the closest preceding aria-level=1 row is the expected section header. + */ + async function assertRoomInSection(page: Page, sectionName: string, roomName: string): Promise { + const roomList = getRoomList(page); + const roomRow = roomList.getByRole("row", { name: `Open room ${roomName}` }); + // Room row must be at aria-level=2 (i.e. inside a section) + await expect(roomRow).toHaveAttribute("aria-level", "2"); + // The closest preceding aria-level=1 row must be the expected section header. + // XPath preceding:: axis returns nodes before the context in document order; [1] picks the nearest one. + const closestSectionHeader = roomRow.locator(`xpath=preceding::*[@role="row" and @aria-level="1"][1]`); + await expect(closestSectionHeader).toContainText(sectionName); + } + + test.beforeEach(async ({ page, app, user }) => { + // The notification toast is displayed above the search section + await app.closeNotificationToast(); + await app.closeVerifyToast(); + + // Focus the user menu to avoid hover decoration + await page.getByRole("button", { name: "User menu" }).focus(); + }); + + test.describe("Section creation", () => { + test("should create a custom section via the header compose menu", async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + + await createCustomSection(page, "Work"); + + // The custom section header should be visible (even though it is empty) + await expect(getSectionHeader(page, "Work")).toBeVisible(); + // The Chats section should also be visible + await expect(getSectionHeader(page, "Chats")).toBeVisible(); + }); + + test("should show 'Section created' toast after creating a section", async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + + await createCustomSection(page, "Personal"); + + // The "Section created" toast should appear + await expect(page.getByText("Section created")).toBeVisible(); + }); + + test("should create a custom section via the room option menu", async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + + const roomList = getRoomList(page); + const roomItem = roomList.getByRole("option", { name: "Open room my room" }); + await expect(roomItem).toBeVisible(); + + // Open the More Options menu + await roomItem.hover(); + await roomItem.getByRole("button", { name: "More Options" }).click(); + + // Open the "Move to" submenu + await page.getByRole("menuitem", { name: "Move to" }).hover(); + + // Click on "New section" + await page.getByRole("menuitem", { name: "New section" }).click(); + + // Fill in the section name in the dialog + const dialog = page.getByRole("dialog", { name: "Create a section" }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("textbox", { name: "Section name" }).fill("Projects"); + await dialog.getByRole("button", { name: "Create section" }).click(); + + // Wait for the dialog to close + await expect(dialog).not.toBeVisible(); + + // The custom section should be created + await expect(getSectionHeader(page, "Projects")).toBeVisible(); + + // Room should be moved to the new section + await assertRoomInSection(page, "Projects", "my room"); + }); + + test("should cancel section creation when dialog is dismissed", async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + + const composeMenu = getRoomListHeader(page).getByRole("button", { name: "New conversation" }); + await composeMenu.click(); + await page.getByRole("menuitem", { name: "New section" }).click(); + + // The dialog should appear + const dialog = page.getByRole("dialog", { name: "Create a section" }); + await expect(dialog).toBeVisible(); + + // Cancel the dialog + await dialog.getByRole("button", { name: "Cancel" }).click(); + + // The dialog should close + await expect(dialog).not.toBeVisible(); + + // No custom section should be created - should remain a flat list + await expect(getSectionHeader(page, "Chats")).not.toBeVisible(); + }); + + test("should create multiple custom sections", async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + + await createCustomSection(page, "Work"); + await createCustomSection(page, "Personal"); + + // Both custom sections should be visible + await expect(getSectionHeader(page, "Work")).toBeVisible(); + await expect(getSectionHeader(page, "Personal")).toBeVisible(); + await expect(getSectionHeader(page, "Chats")).toBeVisible(); + }); + }); + + test.describe("Custom section display", () => { + test("should show empty custom sections", async ({ page, app }) => { + // Create a room so the Chats section has something + await app.client.createRoom({ name: "my room" }); + + await createCustomSection(page, "Empty Section"); + + // The custom section should be visible even with no rooms + await expect(getSectionHeader(page, "Empty Section")).toBeVisible(); + // The room should still be in the Chats section + const roomList = getRoomList(page); + await expect(roomList.getByRole("row", { name: "Open room my room" })).toBeVisible(); + }); + + test("should display custom sections between Favourites and Chats", async ({ page, app }) => { + // Create a favourite room + const favouriteId = await app.client.createRoom({ name: "favourite room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId); + + // Create a low priority room + const lowPrioId = await app.client.createRoom({ name: "low prio room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.lowpriority"); + }, lowPrioId); + + // Create a regular room + await app.client.createRoom({ name: "regular room" }); + + // Create a custom section + await createCustomSection(page, "Work"); + + // All section headers should be visible + await expect(getSectionHeader(page, "Favourites")).toBeVisible(); + await expect(getSectionHeader(page, "Work")).toBeVisible(); + // Should be expanded by default + await expect(getSectionHeader(page, "Work")).toHaveAttribute("aria-expanded", "true"); + await expect(getSectionHeader(page, "Chats")).toBeVisible(); + await expect(getSectionHeader(page, "Low Priority")).toBeVisible(); + }); + }); + + test.describe("Section editing", () => { + test("should edit a custom section name via the section header menu", async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + await createCustomSection(page, "Work"); + + // Open the section header menu + const sectionHeader = getSectionHeader(page, "Work"); + await sectionHeader.hover(); + await sectionHeader.getByRole("button", { name: "More options" }).click(); + + // Click "Edit section" + await page.getByRole("menuitem", { name: "Edit section" }).click(); + + // The edit dialog should appear pre-filled with the current name + const dialog = page.getByRole("dialog", { name: "Edit a section" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole("textbox", { name: "Section name" })).toHaveValue("Work"); + + // Change the name and confirm + await dialog.getByRole("textbox", { name: "Section name" }).fill("Personal"); + await dialog.getByRole("button", { name: "Edit section" }).click(); + + // Dialog should close + await expect(dialog).not.toBeVisible(); + + // Section should have the new name + await expect(getSectionHeader(page, "Personal")).toBeVisible(); + await expect(getSectionHeader(page, "Work")).not.toBeVisible(); + }); + }); + + test.describe("Section removal", () => { + test("should move rooms back to Chats when their section is removed", async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + await createCustomSection(page, "Work"); + await createCustomSection(page, "Personal"); + + const roomList = getRoomList(page); + + // Move room to Work section + const roomItem = roomList.getByRole("row", { name: "Open room my room" }); + await roomItem.hover(); + await roomItem.getByRole("button", { name: "More Options" }).click(); + await page.getByRole("menuitem", { name: "Move to" }).hover(); + await page.getByRole("menuitem", { name: "Work" }).click(); + await assertRoomInSection(page, "Work", "my room"); + + // Remove the Work section + const sectionHeader = getSectionHeader(page, "Work"); + await sectionHeader.hover(); + await sectionHeader.getByRole("button", { name: "More options" }).click(); + await page.getByRole("menuitem", { name: "Remove section" }).click(); + const dialog = page.getByRole("dialog", { name: "Remove section?" }); + await dialog.getByRole("button", { name: "Remove section" }).click(); + + // Section should be gone + await expect(getSectionHeader(page, "Work")).not.toBeVisible(); + // Room should now be in the Chats section + await assertRoomInSection(page, "Chats", "my room"); + }); + }); + + test.describe("Adding a room to a custom section", () => { + test("should add a room to a custom section via the More Options menu", async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + await createCustomSection(page, "Work"); + + const roomList = getRoomList(page); + + // Room starts in Chats section (aria-level=2) + const roomItem = roomList.getByRole("row", { name: "Open room my room" }); + await expect(roomItem).toBeVisible(); + + // Open More Options and move to the Work section + await roomItem.hover(); + await roomItem.getByRole("button", { name: "More Options" }).click(); + await page.getByRole("menuitem", { name: "Move to" }).hover(); + await page.getByRole("menuitem", { name: "Work" }).click(); + + // Room should now be nested under the Work section header (aria-level=1 → aria-level=2) + await assertRoomInSection(page, "Work", "my room"); + }); + + test( + "should show 'Chat moved' toast when adding a room to a custom section", + { tag: "@screenshot" }, + async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + await createCustomSection(page, "Work"); + + const roomList = getRoomList(page); + const roomItem = roomList.getByRole("row", { name: "Open room my room" }); + + await roomItem.hover(); + await roomItem.getByRole("button", { name: "More Options" }).click(); + await page.getByRole("menuitem", { name: "Move to" }).hover(); + await page.getByRole("menuitem", { name: "Work" }).click(); + + // The "Chat moved" toast should appear + await expect(page.getByText("Chat moved")).toBeVisible(); + + // Remove focus outline from the room item before taking the screenshot + await page.getByRole("button", { name: "User menu" }).focus(); + + await expect(roomList).toMatchScreenshot("room-list-sections-chat-moved-toast.png"); + }, + ); + + test("should remove a room from a custom section when toggling the same section", async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + await createCustomSection(page, "Work"); + + const roomList = getRoomList(page); + + // Move to Work section and verify placement via aria-level + let roomItem = roomList.getByRole("row", { name: "Open room my room" }); + await roomItem.hover(); + await roomItem.getByRole("button", { name: "More Options" }).click(); + await page.getByRole("menuitem", { name: "Move to" }).hover(); + await page.getByRole("menuitem", { name: "Work" }).click(); + + await assertRoomInSection(page, "Work", "my room"); + + // Toggle off by selecting the same section again + roomItem = roomList.getByRole("row", { name: "Open room my room" }); + await roomItem.hover(); + await roomItem.getByRole("button", { name: "More Options" }).click(); + await page.getByRole("menuitem", { name: "Move to" }).hover(); + await page.getByRole("menuitem", { name: "Work" }).click(); + + // Room is back in the Chats section + await assertRoomInSection(page, "Chats", "my room"); + }); + }); +}); diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts index 99ffed3ff8..495ced07f6 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts @@ -6,10 +6,11 @@ */ import { type Visibility } from "matrix-js-sdk/src/matrix"; -import { type Locator, type Page } from "@playwright/test"; +import { type Page } from "@playwright/test"; import { expect, test } from "../../../element-web-test"; import { SettingLevel } from "../../../../src/settings/SettingLevel"; +import { getFilterCollapseButton, getFilterExpandButton, getPrimaryFilters, getRoomOptionsMenu } from "./utils"; test.describe("Room list filters and sort", () => { test.use({ @@ -21,22 +22,6 @@ test.describe("Room list filters and sort", () => { labsFlags: ["feature_new_room_list"], }); - function getPrimaryFilters(page: Page): Locator { - return page.getByTestId("primary-filters"); - } - - function getRoomOptionsMenu(page: Page): Locator { - return page.getByRole("button", { name: "Room Options" }); - } - - function getFilterExpandButton(page: Page): Locator { - return getPrimaryFilters(page).getByRole("button", { name: "Expand filter list" }); - } - - function getFilterCollapseButton(page: Page): Locator { - return getPrimaryFilters(page).getByRole("button", { name: "Collapse filter list" }); - } - /** * Get the room list * @param page @@ -46,7 +31,8 @@ test.describe("Room list filters and sort", () => { } test.beforeEach(async ({ page, app, bot, user }) => { - // The notification toast is displayed above the search section + // The toasts are displayed above the search section + await app.closeVerifyToast(); await app.closeNotificationToast(); }); diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts index 96e0ca8597..471311c3f6 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts @@ -6,23 +6,16 @@ */ import { test, expect } from "../../../element-web-test"; -import type { Page } from "@playwright/test"; +import { getHeaderSection } from "./utils"; test.describe("Header section of the room list", () => { test.use({ labsFlags: ["feature_new_room_list"], }); - /** - * Get the header section of the room list - * @param page - */ - function getHeaderSection(page: Page) { - return page.getByTestId("room-list-header"); - } - test.beforeEach(async ({ page, app, user }) => { - // The notification toast is displayed above the search section + // The toasts are displayed above the search section + await app.closeVerifyToast(); await app.closeNotificationToast(); }); diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts index bc1387cbce..eae61c8bab 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts @@ -5,25 +5,17 @@ * Please see LICENSE files in the repository root for full details. */ -import { type Page } from "@playwright/test"; - import { test, expect } from "../../../element-web-test"; +import { getRoomListView } from "./utils"; test.describe("Room list panel", () => { test.use({ labsFlags: ["feature_new_room_list"], }); - /** - * Get the room list view - * @param page - */ - function getRoomListView(page: Page) { - return page.getByRole("navigation", { name: "Room list" }); - } - test.beforeEach(async ({ page, app, user }) => { - // The notification toast is displayed above the search section + // The toasts are displayed above the search section + await app.closeVerifyToast(); await app.closeNotificationToast(); // Populate the room list diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-search.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-search.spec.ts index 028503f622..492b3f429d 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-search.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-search.spec.ts @@ -5,25 +5,17 @@ * Please see LICENSE files in the repository root for full details. */ -import { type Page } from "@playwright/test"; - import { test, expect } from "../../../element-web-test"; +import { getSearchSection } from "./utils"; test.describe("Search section of the room list", () => { test.use({ labsFlags: ["feature_new_room_list"], }); - /** - * Get the search section of the room list - * @param page - */ - function getSearchSection(page: Page) { - return page.getByRole("search"); - } - test.beforeEach(async ({ page, app, user }) => { - // The notification toast is displayed above the search section + // The toasts are displayed above the search section + await app.closeVerifyToast(); await app.closeNotificationToast(); }); diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts index 9bc9bbe2b0..d8c12f55da 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts @@ -5,9 +5,8 @@ * Please see LICENSE files in the repository root for full details. */ -import { type Locator, type Page } from "@playwright/test"; - import { expect, test } from "../../../element-web-test"; +import { getPrimaryFilters, getRoomList, getSectionHeader } from "./utils"; test.describe("Room list sections", () => { test.use({ @@ -19,36 +18,9 @@ test.describe("Room list sections", () => { }, }); - /** - * Get the room list - * @param page - */ - function getRoomList(page: Page): Locator { - return page.getByTestId("room-list"); - } - - /** - * Get the primary filters - * @param page - */ - function getPrimaryFilters(page: Page): Locator { - return page.getByTestId("primary-filters"); - } - - /** - * Get a section header toggle button by section name - * @param page - * @param sectionName The display name of the section (e.g. "Favourites", "Chats", "Low Priority") - * @param isUnread Whether to look for the unread version of the section header - */ - function getSectionHeader(page: Page, sectionName: string, isUnread = false): Locator { - return getRoomList(page).getByRole("gridcell", { - name: isUnread ? `Toggle ${sectionName} section with unread room(s)` : `Toggle ${sectionName} section`, - }); - } - test.beforeEach(async ({ page, app, user }) => { - // The notification toast is displayed above the search section + // The toasts are displayed above the search section + await app.closeVerifyToast(); await app.closeNotificationToast(); // focus the user menu to avoid to have hover decoration diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts index 79cc0b4cf3..66d905afb7 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts @@ -10,6 +10,7 @@ import { type Page } from "@playwright/test"; import { expect, test } from "../../../element-web-test"; import { type Bot } from "../../../pages/bot"; import { type ElementAppPage } from "../../../pages/ElementAppPage"; +import { getRoomList } from "./utils"; test.describe("Room list", () => { test.use({ @@ -20,16 +21,9 @@ test.describe("Room list", () => { }, }); - /** - * Get the room list - * @param page - */ - function getRoomList(page: Page) { - return page.getByTestId("room-list"); - } - test.beforeEach(async ({ page, app, user }) => { - // The notification toast is displayed above the search section + // The toasts are displayed above the search section + await app.closeVerifyToast(); await app.closeNotificationToast(); // focus the user menu to avoid to have hover decoration @@ -324,6 +318,10 @@ test.describe("Room list", () => { .click(); await page.getByRole("menuitem", { name: "New video room" }).click(); await page.getByRole("textbox", { name: "Name" }).fill("video room"); + // Make it public to avoid any crypto setup toasts + await page.getByRole("button", { name: "Room visibility" }).click(); + await page.getByRole("option", { name: "Public room" }).click(); + await page.getByRole("textbox", { name: "Room address" }).fill("video-room"); await page.getByRole("button", { name: "Create video room" }).click(); const roomListView = getRoomList(page); diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/utils.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/utils.ts new file mode 100644 index 0000000000..523b268c80 --- /dev/null +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/utils.ts @@ -0,0 +1,92 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type Locator, type Page } from "@playwright/test"; + +/** + * Get the room list + * @param page + */ +export function getRoomList(page: Page): Locator { + return page.getByTestId("room-list"); +} + +/** + * Get the room list header + * @param page + */ +export function getRoomListHeader(page: Page): Locator { + return page.getByTestId("room-list-header"); +} + +/** + * Get a section header toggle button by section name + * @param page + * @param sectionName The display name of the section + * @param isUnread Whether to look for the unread version of the section header + */ +export function getSectionHeader(page: Page, sectionName: string, isUnread = false): Locator { + return getRoomList(page).getByRole("gridcell", { + name: isUnread ? `Toggle ${sectionName} section with unread room(s)` : `Toggle ${sectionName} section`, + }); +} + +/** + * Get the primary filters container + * @param page + */ +export function getPrimaryFilters(page: Page): Locator { + return page.getByTestId("primary-filters"); +} + +/** + * Get the room options menu button in the room list header + * @param page + */ +export function getRoomOptionsMenu(page: Page): Locator { + return page.getByRole("button", { name: "Room Options" }); +} + +/** + * Get the filter list expand button in the room list header + * @param page + */ +export function getFilterExpandButton(page: Page): Locator { + return getPrimaryFilters(page).getByRole("button", { name: "Expand filter list" }); +} + +/** + * Get the filter list collapse button in the room list header + * @param page + */ +export function getFilterCollapseButton(page: Page): Locator { + return getPrimaryFilters(page).getByRole("button", { name: "Collapse filter list" }); +} + +/** + * Get the header section of the room list + * @param page + */ +export function getHeaderSection(page: Page) { + return page.getByTestId("room-list-header"); +} + +/** + * Get the room list view + * @param page + */ +export function getRoomListView(page: Page) { + return page.getByRole("navigation", { name: "Room list" }); +} + +/** + * Get the search section of the room list + * @param page + */ +export function getSearchSection(page: Page) { + return page.getByRole("search"); +} diff --git a/apps/web/playwright/e2e/login/login-consent.spec.ts b/apps/web/playwright/e2e/login/login-consent.spec.ts index 19e095f50a..be8b9669a2 100644 --- a/apps/web/playwright/e2e/login/login-consent.spec.ts +++ b/apps/web/playwright/e2e/login/login-consent.spec.ts @@ -126,7 +126,7 @@ test.describe("Login", () => { await page.goto("/"); // Should give us the welcome page initially - await expect(page.getByRole("heading", { name: "Welcome to Element!" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Be in your element" })).toBeVisible(); // Start the login process await expect(axe).toHaveNoViolations(); diff --git a/apps/web/playwright/e2e/messages/messages.spec.ts b/apps/web/playwright/e2e/messages/messages.spec.ts index 67af9edb42..60fa6a9572 100644 --- a/apps/web/playwright/e2e/messages/messages.spec.ts +++ b/apps/web/playwright/e2e/messages/messages.spec.ts @@ -88,6 +88,7 @@ test.describe("Message rendering", () => { room: async ({ user, app }, use) => { const roomId = await app.client.createRoom({ name: "Test room" }); await use({ roomId }); + await app.closeVerifyToast(); }, }); @@ -218,6 +219,7 @@ test.describe("Message url previews", () => { room: async ({ user, app }, use) => { const roomId = await app.client.createRoom({ name: "Test room" }); await use({ roomId }); + await app.closeVerifyToast(); }, }); test("should render a basic preview", { tag: "@screenshot" }, async ({ page, user, app, room, axe }) => { @@ -252,6 +254,7 @@ test.describe("Message url previews", () => { "og:title": "A simple site", "og:description": "And with a brief description", "og:image": mxc, + "og:image:alt": "The riot logo", }, }); }); diff --git a/apps/web/playwright/e2e/oidc/oidc-native.spec.ts b/apps/web/playwright/e2e/oidc/oidc-native.spec.ts index 81964c4e64..6d6c4612c1 100644 --- a/apps/web/playwright/e2e/oidc/oidc-native.spec.ts +++ b/apps/web/playwright/e2e/oidc/oidc-native.spec.ts @@ -148,7 +148,8 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => { const userId = `alice_${testInfo.testId}`; await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, "Pa$sW0rD!"); - await expect(page.getByText("Welcome")).toBeVisible(); + // richvdh: This takes several seconds to happen on a dev instance + await expect(page.getByText("Welcome")).toBeVisible({ timeout: 10000 }); // Log out await page.getByRole("button", { name: "User menu" }).click(); @@ -162,11 +163,14 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => { // Log in again await page.goto("/#/login"); + await expect(page.getByText("Sign in")).toBeVisible(); await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.getByText("Continue to Element?")).toBeVisible(); await page.getByRole("button", { name: "Continue" }).click(); // We should be being warned that we need to verify (but we can't) - await expect(page.getByText("Confirm your digital identity")).toBeVisible(); + // richvdh: Again, Element takes several seconds to load on a dev instance + await expect(page.getByText("Confirm your digital identity")).toBeVisible({ timeout: 10000 }); // And there should be no way to close this prompt await expect(page.getByRole("button", { name: "Skip verification for now" })).not.toBeVisible(); diff --git a/apps/web/playwright/e2e/read-receipts/read-receipts.spec.ts b/apps/web/playwright/e2e/read-receipts/read-receipts.spec.ts index b1142bae59..9e38cd4d71 100644 --- a/apps/web/playwright/e2e/read-receipts/read-receipts.spec.ts +++ b/apps/web/playwright/e2e/read-receipts/read-receipts.spec.ts @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import type { JSHandle } from "@playwright/test"; -import type { MatrixEvent, ISendEventResponse, ReceiptType } from "matrix-js-sdk/src/matrix"; +import type { MatrixEvent, ISendEventResponse, ReceiptType, RelationType } from "matrix-js-sdk/src/matrix"; import { expect } from "../../element-web-test"; import { type ElementAppPage } from "../../pages/ElementAppPage"; import { type Bot } from "../../pages/bot"; @@ -47,7 +47,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { getId: () => eventResponse.event_id, threadRootId, getTs: () => 1, - isRelation: (relType) => { + isRelation: (relType: RelationType) => { return !relType || relType === "m.thread"; }, } as any as MatrixEvent; diff --git a/apps/web/playwright/e2e/room-directory/room-directory.spec.ts b/apps/web/playwright/e2e/room-directory/room-directory.spec.ts index 6eea5abce7..e0cb32c7fb 100644 --- a/apps/web/playwright/e2e/room-directory/room-directory.spec.ts +++ b/apps/web/playwright/e2e/room-directory/room-directory.spec.ts @@ -65,6 +65,7 @@ test.describe("Room Directory", () => { room_alias_name: "test1234", }); + await app.closeVerifyToast(); await page.getByRole("button", { name: "Explore rooms" }).click(); const dialog = page.locator(".mx_SpotlightDialog"); diff --git a/apps/web/playwright/e2e/room/create-room.spec.ts b/apps/web/playwright/e2e/room/create-room.spec.ts index 554f972c7d..82ae2dddda 100644 --- a/apps/web/playwright/e2e/room/create-room.spec.ts +++ b/apps/web/playwright/e2e/room/create-room.spec.ts @@ -22,6 +22,7 @@ test.describe("Create Room", () => { "should create a public room with name, topic & address set", { tag: "@screenshot" }, async ({ page, user, app, axe }) => { + await app.closeVerifyToast(); const dialog = await app.openCreateRoomDialog(); // Fill name & topic await dialog.getByRole("textbox", { name: "Name" }).fill(name); @@ -50,6 +51,8 @@ test.describe("Create Room", () => { ); test("should allow us to start a chat and show encryption state", async ({ page, user, app }) => { + await app.closeVerifyToast(); + await page.getByRole("button", { name: "New conversation", exact: true }).click(); await page.getByRole("menuitem", { name: "Start chat" }).click(); @@ -57,6 +60,9 @@ test.describe("Create Room", () => { await page.getByRole("button", { name: "Go" }).click(); + await expect(page.getByRole("heading", { name: "Start a chat with this new contact?" })).toBeVisible(); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.getByText("Encryption enabled")).toBeVisible(); await expect(page.getByText("Send your first message to")).toBeVisible(); @@ -66,6 +72,7 @@ test.describe("Create Room", () => { test("should create a video room", { tag: "@screenshot" }, async ({ page, user, app }) => { await app.settings.setValue("feature_video_rooms", null, SettingLevel.DEVICE, true); + await app.closeVerifyToast(); const dialog = await app.openCreateRoomDialog("New video room"); // Fill name & topic @@ -100,6 +107,7 @@ test.describe("Create Room", () => { }); test("should disallow creating public rooms", { tag: "@screenshot" }, async ({ page, user, app, axe }) => { + await app.closeVerifyToast(); const dialog = await app.openCreateRoomDialog(); // Fill name & topic await dialog.getByRole("textbox", { name: "Name" }).fill(name); @@ -125,7 +133,9 @@ test.describe("Create Room", () => { test.describe("when the encrypted state labs flag is turned off", () => { test.use({ labsFlags: [] }); - test("creates a room without encrypted state", { tag: "@screenshot" }, async ({ page, user: _user }) => { + test("creates a room without encrypted state", { tag: "@screenshot" }, async ({ page, user: _user, app }) => { + await app.closeVerifyToast(); + // When we start to create a room await page.getByRole("button", { name: "New conversation", exact: true }).click(); await page.getByRole("menuitem", { name: "New room" }).click(); @@ -154,7 +164,9 @@ test.describe("Create Room", () => { test( "creates a room with encrypted state if we check the box", { tag: "@screenshot" }, - async ({ page, user: _user }) => { + async ({ page, user: _user, app }) => { + await app.closeVerifyToast(); + // Given we check the Encrypted State checkbox await page.getByRole("button", { name: "New conversation", exact: true }).click(); await page.getByRole("menuitem", { name: "New room" }).click(); @@ -181,7 +193,9 @@ test.describe("Create Room", () => { test( "creates a room without encrypted state if we don't check the box", { tag: "@screenshot" }, - async ({ page, user: _user }) => { + async ({ page, user: _user, app }) => { + await app.closeVerifyToast(); + // Given we did not check the Encrypted State checkbox await page.getByRole("button", { name: "New conversation", exact: true }).click(); await page.getByRole("menuitem", { name: "New room" }).click(); diff --git a/apps/web/playwright/e2e/room/room-status-bar.spec.ts b/apps/web/playwright/e2e/room/room-status-bar.spec.ts index 78d5c49a30..52cd4cabd3 100644 --- a/apps/web/playwright/e2e/room/room-status-bar.spec.ts +++ b/apps/web/playwright/e2e/room/room-status-bar.spec.ts @@ -19,6 +19,7 @@ test.describe("Room Status Bar", () => { const roomId = await app.client.createRoom({ name: "A room", }); + await app.closeVerifyToast(); await app.closeNotificationToast(); await app.viewRoomById(roomId); await use({ roomId }); @@ -139,6 +140,7 @@ test.describe("Room Status Bar", () => { "should show an error when creating a local room fails", { tag: "@screenshot" }, async ({ page, app, user, bot }) => { + await app.closeVerifyToast(); await page .getByRole("navigation", { name: "Room list" }) .getByRole("button", { name: "New conversation" }) @@ -163,6 +165,10 @@ test.describe("Room Status Bar", () => { ).toBeVisible(); await other.getByRole("option", { name: "Alice" }).click(); await other.getByRole("button", { name: "Go" }).click(); + + await expect(page.getByRole("heading", { name: "Start a chat with this new contact?" })).toBeVisible(); + await page.getByRole("button", { name: "Continue" }).click(); + // Send a message to invite the bots const composer = app.getComposerField(); await composer.fill("Hello"); diff --git a/apps/web/playwright/e2e/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts b/apps/web/playwright/e2e/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts index 457c5c17cf..8bb89c9c9c 100644 --- a/apps/web/playwright/e2e/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts +++ b/apps/web/playwright/e2e/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts @@ -14,6 +14,7 @@ test.describe("Appearance user settings tab", () => { }); test("should be rendered properly", { tag: "@screenshot" }, async ({ page, user, app, axe }) => { + await app.closeVerifyToast(); const tab = await app.settings.openUserSettings("Appearance"); // Click "Show advanced" link button @@ -31,6 +32,7 @@ test.describe("Appearance user settings tab", () => { "should support changing font size by using the font size dropdown", { tag: "@screenshot" }, async ({ page, app, user }) => { + await app.closeVerifyToast(); await app.settings.openUserSettings("Appearance"); const tab = page.getByTestId("mx_AppearanceUserSettingsTab"); @@ -46,6 +48,7 @@ test.describe("Appearance user settings tab", () => { ); test("should support enabling system font", async ({ page, app, user }) => { + await app.closeVerifyToast(); await app.settings.openUserSettings("Appearance"); const tab = page.getByTestId("mx_AppearanceUserSettingsTab"); @@ -63,7 +66,10 @@ test.describe("Appearance user settings tab", () => { "should keep same font and emoji when switching theme", { tag: "@screenshot" }, async ({ page, app, user, util }) => { + await app.closeVerifyToast(); + const roomId = await util.createAndDisplayRoom(); + await app.client.sendMessage(roomId, { body: "Message with 🦡", msgtype: "m.text" }); await app.settings.openUserSettings("Appearance"); diff --git a/apps/web/playwright/e2e/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts b/apps/web/playwright/e2e/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts index cb38f923e9..f24fb084af 100644 --- a/apps/web/playwright/e2e/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts +++ b/apps/web/playwright/e2e/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts @@ -17,6 +17,8 @@ test.describe("Appearance user settings tab", () => { test.beforeEach(async ({ app, user, util }) => { // Disable the default theme for consistency in case ThemeWatcher automatically chooses it await util.disableSystemTheme(); + await app.closeVerifyToast(); + await util.openAppearanceTab(); }); @@ -102,6 +104,7 @@ test.describe("Appearance user settings tab", () => { await expect(page).toMatchScreenshot("window-custom-theme.png"); await page.reload(); + await app.closeVerifyToast(); await util.openAppearanceTab(); // Assert that the custom theme is still selected after reloading the page diff --git a/apps/web/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts b/apps/web/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts index a53a188d6b..3f5383ae30 100644 --- a/apps/web/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts +++ b/apps/web/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts @@ -85,6 +85,7 @@ test.describe("Encryption tab", () => { // Fill the recovery key await util.enterRecoveryKey(recoveryKey); + await dialog.getByRole("heading", { name: "Key storage" }).scrollIntoViewIfNeeded(); await expect(dialog).toMatchScreenshot("default-tab.png", { mask: [dialog.getByTestId("deviceId"), dialog.getByTestId("sessionKey")], }); diff --git a/apps/web/playwright/e2e/settings/encryption-user-tab/other-devices.spec.ts b/apps/web/playwright/e2e/settings/encryption-user-tab/other-devices.spec.ts index 6c20af2d9a..790d99aba7 100644 --- a/apps/web/playwright/e2e/settings/encryption-user-tab/other-devices.spec.ts +++ b/apps/web/playwright/e2e/settings/encryption-user-tab/other-devices.spec.ts @@ -6,10 +6,13 @@ */ import { createNewInstance } from "@element-hq/element-web-playwright-common"; +import { type StartedHomeserverContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers"; +import { type Page, type Browser, type TestInfo } from "@playwright/test"; import { test, expect } from "./index"; import { ElementAppPage } from "../../../pages/ElementAppPage"; import { createRoom, sendMessageInCurrentRoom, verifyApp } from "../../crypto/utils"; +import { type CredentialsOptionalAccessToken } from "../../../pages/bot"; test.describe("Other people's devices section in Encryption tab", () => { test.use({ @@ -23,21 +26,13 @@ test.describe("Other people's devices section in Encryption tab", () => { browser, user: aliceCredentials, }, testInfo) => { - await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials); + await prepForEncryption(aliceElementApp, aliceCredentials); // Create a second browser instance. - const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "bob"); - const bobPage = await createNewInstance(browser, bobCredentials, {}); - const bobElementApp = new ElementAppPage(bobPage); - await bobElementApp.client.bootstrapCrossSigning(bobCredentials); + const { bobCredentials, bobPage } = await newBrowser(homeserver, testInfo, browser); // Create the room and invite bob - await createRoom(alicePage, "TestRoom", true); - await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); - - // Bob accepts the invite - await bobPage.getByRole("option", { name: "TestRoom" }).click(); - await bobPage.getByRole("button", { name: "Accept" }).click(); + await inviteBobToNewRoom(alicePage, aliceElementApp, bobCredentials, bobPage); // Alice sends a message, which Bob should be able to decrypt await sendMessageInCurrentRoom(alicePage, "Decryptable"); @@ -52,7 +47,7 @@ test.describe("Other people's devices section in Encryption tab", () => { user: aliceCredentials, util, }, testInfo) => { - await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials); + await prepForEncryption(aliceElementApp, aliceCredentials); // Enable blacklist toggle. const dialog = await util.openEncryptionTab(); @@ -65,18 +60,10 @@ test.describe("Other people's devices section in Encryption tab", () => { await aliceElementApp.settings.closeDialog(); // Create a second browser instance. - const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "bob"); - const bobPage = await createNewInstance(browser, bobCredentials, {}); - const bobElementApp = new ElementAppPage(bobPage); - await bobElementApp.client.bootstrapCrossSigning(bobCredentials); + const { bobCredentials, bobPage } = await newBrowser(homeserver, testInfo, browser); // Create the room and invite bob - await createRoom(alicePage, "TestRoom", true); - await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); - - // Bob accepts the invite - await bobPage.getByRole("option", { name: "TestRoom" }).click(); - await bobPage.getByRole("button", { name: "Accept" }).click(); + await inviteBobToNewRoom(alicePage, aliceElementApp, bobCredentials, bobPage); // Alice sends a message, which Bob should not be able to decrypt await sendMessageInCurrentRoom(alicePage, "Undecryptable"); @@ -95,7 +82,7 @@ test.describe("Other people's devices section in Encryption tab", () => { user: aliceCredentials, util, }, testInfo) => { - await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials); + await prepForEncryption(aliceElementApp, aliceCredentials); // Enable blacklist toggle. const dialog = await util.openEncryptionTab(); @@ -108,21 +95,11 @@ test.describe("Other people's devices section in Encryption tab", () => { await aliceElementApp.settings.closeDialog(); // Create a second browser instance. - const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "bob"); - const bobPage = await createNewInstance(browser, bobCredentials, {}); - const bobElementApp = new ElementAppPage(bobPage); - await bobElementApp.client.bootstrapCrossSigning(bobCredentials); + const { bobCredentials, bobPage, bobElementApp } = await newBrowser(homeserver, testInfo, browser); // Create the room and invite bob - await createRoom(alicePage, "TestRoom", true); - await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); - - // Bob accepts the invite and dismisses the warnings. - await bobPage.getByRole("option", { name: "TestRoom" }).click(); - await bobPage.getByRole("button", { name: "Accept" }).click(); - await bobPage.getByRole("button", { name: "Dismiss" }).click(); // enable notifications - await bobPage.getByRole("button", { name: "Dismiss" }).click(); // enable key storage - await bobPage.getByRole("button", { name: "Yes, dismiss" }).click(); // enable key storage x2 + await inviteBobToNewRoom(alicePage, aliceElementApp, bobCredentials, bobPage); + await bobElementApp.closeNotificationToast(); // Perform verification. await verifyApp("alice", aliceElementApp, "bob", bobElementApp); @@ -139,21 +116,13 @@ test.describe("Other people's devices section in Encryption tab", () => { browser, user: aliceCredentials, }, testInfo) => { - await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials); + await prepForEncryption(aliceElementApp, aliceCredentials); // Create a second browser instance. - const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "bob"); - const bobPage = await createNewInstance(browser, bobCredentials, {}); - const bobElementApp = new ElementAppPage(bobPage); - await bobElementApp.client.bootstrapCrossSigning(bobCredentials); + const { bobCredentials, bobPage } = await newBrowser(homeserver, testInfo, browser); - // Alice creates the room and invite Bob. - await createRoom(alicePage, "TestRoom", true); - await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); - - // Bob accepts the invite. - await bobPage.getByRole("option", { name: "TestRoom" }).click(); - await bobPage.getByRole("button", { name: "Accept" }).click(); + // Alice creates the room and invites Bob. + await inviteBobToNewRoom(alicePage, aliceElementApp, bobCredentials, bobPage); // Alice configures her client to blacklist unverified users in this room. const dialog = await aliceElementApp.settings.openRoomSettings("Security & Privacy"); @@ -168,10 +137,6 @@ test.describe("Other people's devices section in Encryption tab", () => { ), ).toBeVisible(); - // Alice dismisses key storage warnings, as they now hide the "New conversation" button. - await alicePage.getByRole("button", { name: "Dismiss" }).click(); // enable key storage - await alicePage.getByRole("button", { name: "Yes, dismiss" }).click(); // enable key storage x2 - // Alice creates a second room and invites Bob. await createRoom(alicePage, "TestRoom2", true); await aliceElementApp.toggleRoomInfoPanel(); // should not be necessary, called in body of below @@ -194,7 +159,7 @@ test.describe("Other people's devices section in Encryption tab", () => { user: aliceCredentials, util, }, testInfo) => { - await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials); + await prepForEncryption(aliceElementApp, aliceCredentials); // Enable blacklist toggle. let dialog = await util.openEncryptionTab(); @@ -207,18 +172,10 @@ test.describe("Other people's devices section in Encryption tab", () => { await aliceElementApp.settings.closeDialog(); // Create a second browser instance. - const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "bob"); - const bobPage = await createNewInstance(browser, bobCredentials, {}); - const bobElementApp = new ElementAppPage(bobPage); - await bobElementApp.client.bootstrapCrossSigning(bobCredentials); + const { bobCredentials, bobPage } = await newBrowser(homeserver, testInfo, browser); - // Alice creates the room and invite Bob. - await createRoom(alicePage, "TestRoom", true); - await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); - - // Bob accepts the invite. - await bobPage.getByRole("option", { name: "TestRoom" }).click(); - await bobPage.getByRole("button", { name: "Accept" }).click(); + // Alice creates the room and invites Bob. + await inviteBobToNewRoom(alicePage, aliceElementApp, bobCredentials, bobPage); // Alice configures her client to allow sending to unverified users in this room. dialog = await aliceElementApp.settings.openRoomSettings("Security & Privacy"); @@ -229,10 +186,6 @@ test.describe("Other people's devices section in Encryption tab", () => { await sendMessageInCurrentRoom(alicePage, "Decryptable"); await expect(bobPage.getByText("Decryptable")).toBeVisible(); - // Alice dismisses key storage warnings, as they now hide the "New conversation" button. - await alicePage.getByRole("button", { name: "Dismiss" }).click(); // enable key storage - await alicePage.getByRole("button", { name: "Yes, dismiss" }).click(); // enable key storage x2 - // Alice creates a second room and invites Bob. await createRoom(alicePage, "TestRoom2", true); await aliceElementApp.toggleRoomInfoPanel(); // should not be necessary, called in body of below @@ -251,3 +204,32 @@ test.describe("Other people's devices section in Encryption tab", () => { ).toBeVisible(); }); }); + +async function inviteBobToNewRoom( + alicePage: Page, + aliceElementApp: ElementAppPage, + bobCredentials: CredentialsOptionalAccessToken, + bobPage: Page, +) { + await createRoom(alicePage, "TestRoom", true); + await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true }); + await bobPage.getByRole("option", { name: "TestRoom" }).click(); + await bobPage.getByRole("button", { name: "Accept" }).click(); +} + +async function newBrowser( + homeserver: StartedHomeserverContainer, + testInfo: TestInfo, + browser: Browser, +): Promise<{ bobCredentials: CredentialsOptionalAccessToken; bobPage: Page; bobElementApp: ElementAppPage }> { + const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "bob"); + const bobPage = await createNewInstance(browser, bobCredentials, {}); + const bobElementApp = new ElementAppPage(bobPage); + await prepForEncryption(bobElementApp, bobCredentials); + return { bobCredentials, bobPage, bobElementApp }; +} + +async function prepForEncryption(app: ElementAppPage, credentials: CredentialsOptionalAccessToken): Promise { + await app.client.bootstrapCrossSigning(credentials); + await app.closeKeyStorageToast(); +} diff --git a/apps/web/playwright/e2e/settings/security-user-settings-tab.spec.ts b/apps/web/playwright/e2e/settings/security-user-settings-tab.spec.ts index 25f430c68d..6abc7d3491 100644 --- a/apps/web/playwright/e2e/settings/security-user-settings-tab.spec.ts +++ b/apps/web/playwright/e2e/settings/security-user-settings-tab.spec.ts @@ -26,7 +26,8 @@ test.describe("Security user settings tab", () => { }); test.beforeEach(async ({ page, app, user }) => { - // Dismiss "Notification" toast + // Dismiss toasts + await app.closeVerifyToast(); await app.closeNotificationToast(); await page.locator(".mx_Toast_buttons").getByRole("button", { name: "Yes" }).click(); // Allow analytics }); diff --git a/apps/web/playwright/e2e/sliding-sync/sliding-sync.spec.ts b/apps/web/playwright/e2e/sliding-sync/sliding-sync.spec.ts index cde2d57c14..108f0520b3 100644 --- a/apps/web/playwright/e2e/sliding-sync/sliding-sync.spec.ts +++ b/apps/web/playwright/e2e/sliding-sync/sliding-sync.spec.ts @@ -72,6 +72,7 @@ test.describe("Sliding Sync", () => { // Load the user fixture for all tests test.beforeEach(async ({ app, user }) => { + await app.closeVerifyToast(); await app.closeNotificationToast(); }); diff --git a/apps/web/playwright/e2e/spaces/spaces.spec.ts b/apps/web/playwright/e2e/spaces/spaces.spec.ts index 72b7873920..173130fd29 100644 --- a/apps/web/playwright/e2e/spaces/spaces.spec.ts +++ b/apps/web/playwright/e2e/spaces/spaces.spec.ts @@ -68,6 +68,7 @@ test.describe("Spaces", () => { "should allow user to create public space", { tag: ["@screenshot", "@no-webkit"] }, async ({ page, app, user }) => { + await app.closeVerifyToast(); const contextMenu = await openSpaceCreateMenu(page); await expect(contextMenu).toMatchScreenshot("space-create-menu.png"); @@ -104,6 +105,7 @@ test.describe("Spaces", () => { ); test("should allow user to create private space", { tag: "@screenshot" }, async ({ page, app, user }) => { + await app.closeVerifyToast(); const menu = await openSpaceCreateMenu(page); await menu.getByRole("button", { name: "Private" }).click(); @@ -150,6 +152,7 @@ test.describe("Spaces", () => { name: "Sample Room", }); + await app.closeVerifyToast(); const menu = await openSpaceCreateMenu(page); await menu.getByRole("button", { name: "Private" }).click(); @@ -184,6 +187,7 @@ test.describe("Spaces", () => { name: "A Room that will not be selected", }); + await app.closeVerifyToast(); const menu = await openSpaceCreateMenu(page); await menu.getByRole("button", { name: "Private" }).click(); @@ -283,6 +287,8 @@ test.describe("Spaces", () => { "should render subspaces in the space panel only when expanded", { tag: "@screenshot" }, async ({ page, app, user, axe }) => { + await app.closeVerifyToast(); + axe.disableRules([ // Disable this check as it triggers on nested roving tab index elements which are in practice fine "nested-interactive", @@ -404,6 +410,7 @@ test.describe("Spaces", () => { }); test("should disallow creating public rooms", { tag: "@screenshot" }, async ({ page, user, app }) => { + await app.closeVerifyToast(); const menu = await openSpaceCreateMenu(page); await menu .locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]') diff --git a/apps/web/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts b/apps/web/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts index eec28099a5..a22ce1ec2b 100644 --- a/apps/web/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts +++ b/apps/web/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts @@ -24,7 +24,9 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { test( "should have the button correctly aligned and displayed in the space panel when expanded", { tag: "@screenshot" }, - async ({ util }) => { + async ({ util, app }) => { + await app.closeVerifyToast(); + // Open the space panel await util.expandSpacePanel(); // The buttons in the space panel should be aligned when expanded @@ -32,7 +34,9 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { }, ); - test("should not show indicator when there is no thread", { tag: "@screenshot" }, async ({ room1, util }) => { + test("should not show indicator when there is no thread", { tag: "@screenshot" }, async ({ room1, util, app }) => { + await app.closeVerifyToast(); + // No indicator should be shown await util.assertNoTacIndicator(); @@ -43,7 +47,14 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { await util.assertNoTacIndicator(); }); - test("should show a notification indicator when there is a message in a thread", async ({ room1, util, msg }) => { + test("should show a notification indicator when there is a message in a thread", async ({ + room1, + util, + msg, + app, + }) => { + await app.closeVerifyToast(); + await util.goTo(room1); await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); @@ -56,7 +67,10 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { util, msg, user, + app, }) => { + await app.closeVerifyToast(); + await util.goTo(room1); await util.receiveMessages(room1, [ "Msg1", @@ -77,7 +91,9 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { test( "should show the rooms with unread threads", { tag: "@screenshot" }, - async ({ room1, room2, util, msg, user }) => { + async ({ room1, room2, util, msg, user, app }) => { + await app.closeVerifyToast(); + await util.goTo(room2); await util.populateThreads(room1, room2, msg, user); // The indicator should be shown @@ -95,30 +111,38 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { }, ); - test("should update with a thread is read", { tag: "@screenshot" }, async ({ room1, room2, util, msg, user }) => { - await util.goTo(room2); - await util.populateThreads(room1, room2, msg, user); + test( + "should update with a thread is read", + { tag: "@screenshot" }, + async ({ room1, room2, util, msg, user, app }) => { + await app.closeVerifyToast(); - // Click on the first room in TAC - await util.openTac(); - await util.clickRoomInTac(room2.name); + await util.goTo(room2); + await util.populateThreads(room1, room2, msg, user); - // Verify that the thread panel is opened after a click on the room in the TAC - await util.assertThreadPanelIsOpened(); + // Click on the first room in TAC + await util.openTac(); + await util.clickRoomInTac(room2.name); - // Open a thread and mark it as read - // The room 2 doesn't have a mention anymore in its unread, so the highest notification level is notification - await util.openThread("Msg1"); - await util.assertNotificationTac(); - await util.openTac(); - await util.assertRoomsInTac([ - { room: room1.name, notificationLevel: "notification" }, - { room: room2.name, notificationLevel: "notification" }, - ]); - await expect(util.getTacPanel()).toMatchScreenshot("tac-panel-notification-unread.png"); - }); + // Verify that the thread panel is opened after a click on the room in the TAC + await util.assertThreadPanelIsOpened(); + + // Open a thread and mark it as read + // The room 2 doesn't have a mention anymore in its unread, so the highest notification level is notification + await util.openThread("Msg1"); + await util.assertNotificationTac(); + await util.openTac(); + await util.assertRoomsInTac([ + { room: room1.name, notificationLevel: "notification" }, + { room: room2.name, notificationLevel: "notification" }, + ]); + await expect(util.getTacPanel()).toMatchScreenshot("tac-panel-notification-unread.png"); + }, + ); + + test("should order by recency after notification level", async ({ room1, room2, util, msg, user, app }) => { + await app.closeVerifyToast(); - test("should order by recency after notification level", async ({ room1, room2, util, msg, user }) => { await util.goTo(room2); await util.populateThreads(room1, room2, msg, user, false); @@ -129,7 +153,9 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { ]); }); - test("should block the Spotlight to open when the TAC is opened", async ({ util, page }) => { + test("should block the Spotlight to open when the TAC is opened", async ({ util, page, app }) => { + await app.closeVerifyToast(); + const toggleSpotlight = () => page.keyboard.press(`${CommandOrControl}+k`); // Sanity check @@ -144,7 +170,9 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { await expect(page.locator(".mx_SpotlightDialog")).not.toBeVisible(); }); - test("should have the correct hover state", { tag: "@screenshot" }, async ({ util, page }) => { + test("should have the correct hover state", { tag: "@screenshot" }, async ({ util, page, app }) => { + await app.closeVerifyToast(); + await util.hoverTacButton(); await expect(util.getSpacePanel()).toMatchScreenshot("tac-hovered.png"); @@ -154,7 +182,9 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { await expect(util.getSpacePanel()).toMatchScreenshot("tac-hovered-expanded.png"); }); - test("should mark all threads as read", { tag: "@screenshot" }, async ({ room1, room2, util, msg, page }) => { + test("should mark all threads as read", { tag: "@screenshot" }, async ({ room1, room2, util, msg, page, app }) => { + await app.closeVerifyToast(); + await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); await util.assertNotificationTac(); @@ -167,7 +197,9 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { await util.assertNoTacIndicator(); }); - test("should focus the thread tab when clicking an item in the TAC", async ({ room1, room2, util, msg }) => { + test("should focus the thread tab when clicking an item in the TAC", async ({ room1, room2, util, msg, app }) => { + await app.closeVerifyToast(); + await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); await util.openTac(); diff --git a/apps/web/playwright/e2e/timeline/media-preview-settings.spec.ts b/apps/web/playwright/e2e/timeline/media-preview-settings.spec.ts index f88312f682..026c74d926 100644 --- a/apps/web/playwright/e2e/timeline/media-preview-settings.spec.ts +++ b/apps/web/playwright/e2e/timeline/media-preview-settings.spec.ts @@ -36,6 +36,8 @@ test.describe("Media preview settings", () => { }); test("should be able to hide avatars of inviters", { tag: "@screenshot" }, async ({ page, app, room, user }) => { + await app.closeVerifyToast(); + let settings = await app.settings.openUserSettings("Preferences"); await settings.getByLabel("Hide avatars of room and inviter").click(); await app.closeDialog(); diff --git a/apps/web/playwright/e2e/timeline/timeline.spec.ts b/apps/web/playwright/e2e/timeline/timeline.spec.ts index 8a1c51b45f..994cd4a101 100644 --- a/apps/web/playwright/e2e/timeline/timeline.spec.ts +++ b/apps/web/playwright/e2e/timeline/timeline.spec.ts @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import type { Locator, Page } from "@playwright/test"; -import type { ISendEventResponse, EventType, MsgType } from "matrix-js-sdk/src/matrix"; +import type { ISendEventResponse, EventType, MsgType, IContent } from "matrix-js-sdk/src/matrix"; import { test, expect } from "../../element-web-test"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import { Layout } from "../../../src/settings/enums/Layout"; @@ -50,11 +50,9 @@ const expectAvatar = async (cli: Client, e: Locator, avatarUrl: string): Promise }; const sendEvent = async (client: Client, roomId: string, html = false): Promise => { - const content = { + const content: IContent = { msgtype: "m.text" as MsgType, body: "Message", - format: undefined, - formatted_body: undefined, }; if (html) { content.format = "org.matrix.custom.html"; @@ -790,6 +788,7 @@ test.describe("Timeline", () => { await sendEvent(app.client, room.roomId); await sendEvent(app.client, room.roomId, true); await page.goto(`/#/room/${room.roomId}`); + await app.closeVerifyToast(); await app.toggleRoomInfoPanel(); @@ -815,6 +814,7 @@ test.describe("Timeline", () => { await sendEvent(app.client, room.roomId); await page.goto(`/#/room/${room.roomId}`); + await app.closeVerifyToast(); // Open a room setting dialog await app.toggleRoomInfoPanel(); diff --git a/apps/web/playwright/e2e/toasts/analytics-toast.spec.ts b/apps/web/playwright/e2e/toasts/analytics-toast.spec.ts index ff10629872..d70f2a7575 100644 --- a/apps/web/playwright/e2e/toasts/analytics-toast.spec.ts +++ b/apps/web/playwright/e2e/toasts/analytics-toast.spec.ts @@ -14,6 +14,7 @@ test.describe("Analytics Toast", () => { }); test("should not show an analytics toast if config has nothing about posthog", async ({ user, toasts }) => { + await toasts.rejectToast("Verify this device"); await toasts.rejectToast("Notifications"); await toasts.assertNoToasts(); }); @@ -29,6 +30,7 @@ test.describe("Analytics Toast", () => { }); test.beforeEach(async ({ user, toasts }) => { + await toasts.rejectToast("Verify this device"); await toasts.rejectToast("Notifications"); }); diff --git a/apps/web/playwright/e2e/utils.ts b/apps/web/playwright/e2e/utils.ts index 49561ed1b4..6c30bf1856 100644 --- a/apps/web/playwright/e2e/utils.ts +++ b/apps/web/playwright/e2e/utils.ts @@ -42,7 +42,7 @@ export async function waitForRoom( return new Promise((resolve) => { const room = matrixClient.getRoom(roomId); - if (window[predicateId](room)) { + if ((window)[predicateId](room)) { resolve(room); return; } @@ -50,7 +50,7 @@ export async function waitForRoom( function onEvent(ev: MatrixEvent) { if (ev.getRoomId() !== roomId) return; - if (window[predicateId](room)) { + if ((window)[predicateId](room)) { matrixClient.removeListener("event" as ClientEvent, onEvent); resolve(room); } diff --git a/apps/web/playwright/e2e/voip/element-call.spec.ts b/apps/web/playwright/e2e/voip/element-call.spec.ts index 4acf5f7aad..7c69a8a896 100644 --- a/apps/web/playwright/e2e/voip/element-call.spec.ts +++ b/apps/web/playwright/e2e/voip/element-call.spec.ts @@ -216,9 +216,9 @@ test.describe("Element Call", () => { }); }); - [true, false].forEach((skipLobbyToggle) => { + [true, false].forEach((joinWithVideo) => { test( - `should be able to join a call via incoming video call toast (skipLobby=${skipLobbyToggle})`, + `should be able to join a call via incoming video call toast (joinWithVideo=${joinWithVideo})`, { tag: ["@screenshot"] }, async ({ page, user, bot, room, app }) => { await app.viewRoomById(room.roomId); @@ -230,7 +230,7 @@ test.describe("Element Call", () => { const toast = page.locator(".mx_Toast_toast"); const button = toast.getByRole("button", { name: "Join" }); - if (skipLobbyToggle) { + if (joinWithVideo) { await toast.getByRole("switch").check(); await expect(toast).toMatchScreenshot(`incoming-call-group-video-toast-checked.png`); } else { @@ -246,8 +246,8 @@ test.describe("Element Call", () => { const hash = new URLSearchParams(url.hash.slice(1)); assertCommonCallParameters(url.searchParams, hash, user, room); - expect(hash.get("intent")).toEqual("join_existing"); - expect(hash.get("skipLobby")).toEqual(skipLobbyToggle.toString()); + expect(hash.get("intent")).toEqual(joinWithVideo ? "join_existing" : "join_existing_voice"); + expect(hash.get("skipLobby")).toEqual("true"); }, ); }); @@ -275,7 +275,7 @@ test.describe("Element Call", () => { const hash = new URLSearchParams(url.hash.slice(1)); assertCommonCallParameters(url.searchParams, hash, user, room); - expect(hash.get("intent")).toEqual("join_existing"); + expect(hash.get("intent")).toEqual("join_existing_voice"); expect(hash.get("skipLobby")).toEqual("true"); }, ); @@ -349,9 +349,9 @@ test.describe("Element Call", () => { expect(hash.get("skipLobby")).toEqual(null); }); - [true, false].forEach((skipLobbyToggle) => { + [true, false].forEach((joinWithVideo) => { test( - `should be able to join a call via incoming call toast (skipLobby=${skipLobbyToggle})`, + `should be able to join a call via incoming call toast (joinWithVideo=${joinWithVideo})`, { tag: ["@screenshot"] }, async ({ page, user, bot, room, app }) => { await app.viewRoomById(room.roomId); @@ -359,14 +359,14 @@ test.describe("Element Call", () => { // Fake a start of a call await sendRTCState(bot, room.roomId, "ring", "video"); const toast = page.locator(".mx_Toast_toast"); - const button = toast.getByRole("button", { name: "Accept" }); - if (skipLobbyToggle) { + const button = toast.getByRole("button", { name: "Join" }); + if (joinWithVideo) { await toast.getByRole("switch").check(); } else { await toast.getByRole("switch").uncheck(); } await expect(toast).toMatchScreenshot( - `incoming-call-dm-video-toast-${skipLobbyToggle ? "checked" : "unchecked"}.png`, + `incoming-call-dm-video-toast-${joinWithVideo ? "checked" : "unchecked"}.png`, { // Hide UserId css: ` @@ -385,8 +385,8 @@ test.describe("Element Call", () => { const hash = new URLSearchParams(url.hash.slice(1)); assertCommonCallParameters(url.searchParams, hash, user, room); - expect(hash.get("intent")).toEqual("join_existing_dm"); - expect(hash.get("skipLobby")).toEqual(skipLobbyToggle.toString()); + expect(hash.get("intent")).toEqual(joinWithVideo ? "join_existing_dm" : "join_existing_dm_voice"); + expect(hash.get("skipLobby")).toEqual("true"); }, ); }); @@ -400,7 +400,7 @@ test.describe("Element Call", () => { // Fake a start of a call await sendRTCState(bot, room.roomId, "ring", "audio"); const toast = page.locator(".mx_Toast_toast"); - const button = toast.getByRole("button", { name: "Accept" }); + const button = toast.getByRole("button", { name: "Join" }); await expect(toast).toMatchScreenshot(`incoming-call-dm-voice-toast.png`, { // Hide UserId diff --git a/apps/web/playwright/e2e/voip/pstn.spec.ts b/apps/web/playwright/e2e/voip/pstn.spec.ts index 4241db6522..e4c08b1f42 100644 --- a/apps/web/playwright/e2e/voip/pstn.spec.ts +++ b/apps/web/playwright/e2e/voip/pstn.spec.ts @@ -21,6 +21,7 @@ test.describe("PSTN", () => { }); test("should render dialpad as expected", { tag: "@screenshot" }, async ({ page, user, toasts }) => { + await toasts.rejectToast("Verify this device"); await toasts.rejectToast("Notifications"); await toasts.assertNoToasts(); diff --git a/apps/web/playwright/element-web-test.ts b/apps/web/playwright/element-web-test.ts index cae16cce8d..62e6515f62 100644 --- a/apps/web/playwright/element-web-test.ts +++ b/apps/web/playwright/element-web-test.ts @@ -107,7 +107,7 @@ export const test = base.extend({ }, }); -interface ExtendedToMatchScreenshotOptions extends ToMatchScreenshotOptions { +export interface ExtendedToMatchScreenshotOptions extends ToMatchScreenshotOptions { includeDialogBackground?: boolean; showTooltips?: boolean; timeout?: number; diff --git a/apps/web/playwright/pages/ElementAppPage.ts b/apps/web/playwright/pages/ElementAppPage.ts index e5a1aab31c..a0add8c83d 100644 --- a/apps/web/playwright/pages/ElementAppPage.ts +++ b/apps/web/playwright/pages/ElementAppPage.ts @@ -233,26 +233,56 @@ export class ElementAppPage { * Open the room info panel, and use it to send an invite to the given user. * * @param userId - The user to invite to the room. + * @param options - Options object */ - public async inviteUserToCurrentRoom(userId: string): Promise { + public async inviteUserToCurrentRoom( + userId: string, + options?: { + /** If true, expect and acknowledge "Confirm inviting new users" page */ + confirmUnknownUser?: boolean; + }, + ): Promise { const rightPanel = await this.openRoomInfoPanel(); await rightPanel.getByRole("menuitem", { name: "Invite" }).click(); - const input = this.page.getByRole("dialog").getByTestId("invite-dialog-input"); + const dialogLocator = this.page.getByRole("dialog"); + const input = dialogLocator.getByTestId("invite-dialog-input"); await input.fill(userId); await input.press("Enter"); - await this.page.getByRole("dialog").getByRole("button", { name: "Invite" }).click(); + await dialogLocator.getByRole("button", { name: "Invite" }).click(); + + if (options?.confirmUnknownUser) { + await expect( + dialogLocator.getByRole("heading", { name: "Invite new contacts to this room?" }), + ).toBeVisible(); + await dialogLocator.getByRole("button", { name: "Invite" }).click(); + } + } + + async closeToast(title: string, button: string): Promise { + await this.page.locator(".mx_Toast_toast", { hasText: title }).getByRole("button", { name: button }).click(); } /** - * Close the notification toast + * Dismiss the "Notifications" toast. */ - public closeNotificationToast(): Promise { - // Dismiss "Notification" toast - return this.page - .locator(".mx_Toast_toast", { hasText: "Notifications" }) - .getByRole("button", { name: "Dismiss" }) - .click(); + public async closeNotificationToast(): Promise { + await this.closeToast("Notifications", "Dismiss"); + } + + /** + * Dismiss the "Turn on key storage" toast. + */ + public async closeKeyStorageToast() { + await this.closeToast("Turn on key storage", "Dismiss"); + await this.page.getByRole("button", { name: "Yes, dismiss" }).click(); + } + + /** + * Dismiss the "Verify this device" toast by clicking "Later". + */ + public async closeVerifyToast() { + await this.closeToast("Verify this device", "Later"); } /** diff --git a/apps/web/playwright/pages/client.ts b/apps/web/playwright/pages/client.ts index 76f2733820..39275085f7 100644 --- a/apps/web/playwright/pages/client.ts +++ b/apps/web/playwright/pages/client.ts @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import { type JSHandle, type Page } from "@playwright/test"; -import { type PageFunctionOn } from "playwright-core/types/structs"; +import { type ElementHandle } from "playwright-core"; import { Network } from "./network"; import type { @@ -30,6 +30,34 @@ import type { import type { RoomMessageEventContent } from "matrix-js-sdk/src/types"; import { type CredentialsOptionalAccessToken } from "./bot"; +/** Types cribbed from playwright-core/types/structs as they are not importable */ +export type NoHandles = Arg extends JSHandle + ? never + : Arg extends object + ? { [Key in keyof Arg]: NoHandles } + : Arg; +export type Unboxed = + Arg extends ElementHandle + ? T + : Arg extends JSHandle + ? T + : Arg extends NoHandles + ? Arg + : Arg extends [infer A0] + ? [Unboxed] + : Arg extends [infer A0, infer A1] + ? [Unboxed, Unboxed] + : Arg extends [infer A0, infer A1, infer A2] + ? [Unboxed, Unboxed, Unboxed] + : Arg extends [infer A0, infer A1, infer A2, infer A3] + ? [Unboxed, Unboxed, Unboxed, Unboxed] + : Arg extends Array + ? Array> + : Arg extends object + ? { [Key in keyof Arg]: Unboxed } + : Arg; +export type PageFunctionOn = string | ((on: On, arg2: Unboxed) => R | Promise); + export class Client { public network: Network; protected client: JSHandle; diff --git a/apps/web/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png b/apps/web/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png index 5439a4cd5a..559de9ab32 100644 Binary files a/apps/web/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png and b/apps/web/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png differ diff --git a/apps/web/playwright/snapshots/crypto/toasts.spec.ts/verify-this-device-linux.png b/apps/web/playwright/snapshots/crypto/toasts.spec.ts/verify-this-device-linux.png index 01ad3384c1..8838386ac3 100644 Binary files a/apps/web/playwright/snapshots/crypto/toasts.spec.ts/verify-this-device-linux.png and b/apps/web/playwright/snapshots/crypto/toasts.spec.ts/verify-this-device-linux.png differ diff --git a/apps/web/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-linux.png b/apps/web/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-linux.png index f764311250..771157a11a 100644 Binary files a/apps/web/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-linux.png and b/apps/web/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-linux.png differ diff --git a/apps/web/playwright/snapshots/invite/invite-dialog.spec.ts/confirm-chat-with-new-contact-linux.png b/apps/web/playwright/snapshots/invite/invite-dialog.spec.ts/confirm-chat-with-new-contact-linux.png new file mode 100644 index 0000000000..4cdb24a173 Binary files /dev/null and b/apps/web/playwright/snapshots/invite/invite-dialog.spec.ts/confirm-chat-with-new-contact-linux.png differ diff --git a/apps/web/playwright/snapshots/invite/invite-dialog.spec.ts/confirm-invite-new-contact-linux.png b/apps/web/playwright/snapshots/invite/invite-dialog.spec.ts/confirm-invite-new-contact-linux.png new file mode 100644 index 0000000000..cf961af67a Binary files /dev/null and b/apps/web/playwright/snapshots/invite/invite-dialog.spec.ts/confirm-invite-new-contact-linux.png differ diff --git a/apps/web/playwright/snapshots/invite/invite-dialog.spec.ts/send-your-first-message-view-linux.png b/apps/web/playwright/snapshots/invite/invite-dialog.spec.ts/send-your-first-message-view-linux.png index 9b14c9e11b..7e795251a8 100644 Binary files a/apps/web/playwright/snapshots/invite/invite-dialog.spec.ts/send-your-first-message-view-linux.png and b/apps/web/playwright/snapshots/invite/invite-dialog.spec.ts/send-your-first-message-view-linux.png differ diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-custom-sections.spec.ts/room-list-sections-chat-moved-toast-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-custom-sections.spec.ts/room-list-sections-chat-moved-toast-linux.png new file mode 100644 index 0000000000..4da4571cbc Binary files /dev/null and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-custom-sections.spec.ts/room-list-sections-chat-moved-toast-linux.png differ diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-collapsed-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-collapsed-linux.png index 6ebcef8532..1f5246ceaf 100644 Binary files a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-collapsed-linux.png and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-collapsed-linux.png differ diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-linux.png index bd61342d60..28f00a9614 100644 Binary files a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-linux.png and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-linux.png differ diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png index d651340ace..77345fe6fe 100644 Binary files a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png differ diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-ltrdisplayname-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-ltrdisplayname-linux.png index 1004849ecb..cf42921ba6 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-ltrdisplayname-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-ltrdisplayname-linux.png differ diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-rtldisplayname-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-rtldisplayname-linux.png index 3673c5a441..179e65ac77 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-rtldisplayname-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-rtldisplayname-linux.png differ diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-ltrdisplayname-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-ltrdisplayname-linux.png index e1b094bd14..34b0d5f4ea 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-ltrdisplayname-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-ltrdisplayname-linux.png differ diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-rtldisplayname-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-rtldisplayname-linux.png index b9e65fce21..e6ba28a064 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-rtldisplayname-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-rtldisplayname-linux.png differ diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-ltr-rtldisplayname-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-ltr-rtldisplayname-linux.png index 10082f77f5..feec9daa94 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-ltr-rtldisplayname-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-ltr-rtldisplayname-linux.png differ diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rich-ltr-rtldisplayname-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rich-ltr-rtldisplayname-linux.png index 6e9d930a22..739fe83bff 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rich-ltr-rtldisplayname-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rich-ltr-rtldisplayname-linux.png differ diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-ltrdisplayname-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-ltrdisplayname-linux.png index 01fbc69609..b0ad809230 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-ltrdisplayname-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-ltrdisplayname-linux.png differ diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rtl-ltrdisplayname-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rtl-ltrdisplayname-linux.png index f6799ebcca..2fc60fd96a 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rtl-ltrdisplayname-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rtl-ltrdisplayname-linux.png differ diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-basic-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-basic-linux.png index 6d004c8da2..a77c782394 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-basic-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-basic-linux.png differ diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-with-thumb-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-with-thumb-linux.png index aa36fca9e9..f04e8d4050 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-with-thumb-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-with-thumb-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png index 98de99bf98..15da4c7c02 100644 Binary files a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png and b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png index 9a1d2cd0ac..e5294b4392 100644 Binary files a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png and b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-after-switch-linux.png b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-after-switch-linux.png index 9a3b6e0054..0a3580977e 100644 Binary files a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-after-switch-linux.png and b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-after-switch-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-before-switch-linux.png b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-before-switch-linux.png index 3861298d82..c90c4fc54f 100644 Binary files a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-before-switch-linux.png and b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-before-switch-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/window-custom-theme-linux.png b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/window-custom-theme-linux.png index 51d8bc87a8..ad8bd39b6d 100644 Binary files a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/window-custom-theme-linux.png and b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/window-custom-theme-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png b/apps/web/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png index 48625def3a..e5b81c1973 100644 Binary files a/apps/web/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png and b/apps/web/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/notifications/notifications-settings-2-tab.spec.ts/standard-notifications-2-settings-linux.png b/apps/web/playwright/snapshots/settings/notifications/notifications-settings-2-tab.spec.ts/standard-notifications-2-settings-linux.png index 8487b5a381..79a9efe5cf 100644 Binary files a/apps/web/playwright/snapshots/settings/notifications/notifications-settings-2-tab.spec.ts/standard-notifications-2-settings-linux.png and b/apps/web/playwright/snapshots/settings/notifications/notifications-settings-2-tab.spec.ts/standard-notifications-2-settings-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/notifications/notifications-settings-tab.spec.ts/standard-notification-settings-linux.png b/apps/web/playwright/snapshots/settings/notifications/notifications-settings-tab.spec.ts/standard-notification-settings-linux.png index 21198098a4..efec129b55 100644 Binary files a/apps/web/playwright/snapshots/settings/notifications/notifications-settings-tab.spec.ts/standard-notification-settings-linux.png and b/apps/web/playwright/snapshots/settings/notifications/notifications-settings-tab.spec.ts/standard-notification-settings-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png b/apps/web/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png index ee058b786a..2519cee3ef 100644 Binary files a/apps/web/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png and b/apps/web/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/security-user-settings-tab.spec.ts/security-settings-tab-linux.png b/apps/web/playwright/snapshots/settings/security-user-settings-tab.spec.ts/security-settings-tab-linux.png index 5248968436..c12d1a8808 100644 Binary files a/apps/web/playwright/snapshots/settings/security-user-settings-tab.spec.ts/security-settings-tab-linux.png and b/apps/web/playwright/snapshots/settings/security-user-settings-tab.spec.ts/security-settings-tab-linux.png differ diff --git a/apps/web/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png b/apps/web/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png index 6ebcb8635f..b6c4e65be2 100644 Binary files a/apps/web/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png and b/apps/web/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png differ diff --git a/apps/web/playwright/snapshots/spaces/spaces.spec.ts/space-visibility-settings-linux.png b/apps/web/playwright/snapshots/spaces/spaces.spec.ts/space-visibility-settings-linux.png index a1d376d62f..1551195fd5 100644 Binary files a/apps/web/playwright/snapshots/spaces/spaces.spec.ts/space-visibility-settings-linux.png and b/apps/web/playwright/snapshots/spaces/spaces.spec.ts/space-visibility-settings-linux.png differ diff --git a/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png b/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png index 5d47b6b9b5..005dae5da0 100644 Binary files a/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png and b/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png differ diff --git a/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png b/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png index 5d47b6b9b5..005dae5da0 100644 Binary files a/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png and b/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png differ diff --git a/apps/web/playwright/snapshots/timeline/timeline.spec.ts/edited-code-block-linux.png b/apps/web/playwright/snapshots/timeline/timeline.spec.ts/edited-code-block-linux.png index 0dc4ad4846..08b47c44f7 100644 Binary files a/apps/web/playwright/snapshots/timeline/timeline.spec.ts/edited-code-block-linux.png and b/apps/web/playwright/snapshots/timeline/timeline.spec.ts/edited-code-block-linux.png differ diff --git a/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png b/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png index 33903a62db..c439f47c80 100644 Binary files a/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png and b/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png differ diff --git a/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png b/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png index c37018b8b5..8383107083 100644 Binary files a/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png and b/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png differ diff --git a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-checked-linux.png b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-checked-linux.png index b0fd216c56..2cf31a3569 100644 Binary files a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-checked-linux.png and b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-checked-linux.png differ diff --git a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-unchecked-linux.png b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-unchecked-linux.png index a7b7aea8a9..c52a3824f1 100644 Binary files a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-unchecked-linux.png and b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-unchecked-linux.png differ diff --git a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-voice-toast-linux.png b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-voice-toast-linux.png index 39a5ec6a19..66c62eb96e 100644 Binary files a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-voice-toast-linux.png and b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-voice-toast-linux.png differ diff --git a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-video-toast-checked-linux.png b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-video-toast-checked-linux.png index f3abb3442d..4a0c736fd4 100644 Binary files a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-video-toast-checked-linux.png and b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-video-toast-checked-linux.png differ diff --git a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-video-toast-unchecked-linux.png b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-video-toast-unchecked-linux.png index 069ef66fe1..99cf03d9d3 100644 Binary files a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-video-toast-unchecked-linux.png and b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-video-toast-unchecked-linux.png differ diff --git a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-voice-toast-linux.png b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-voice-toast-linux.png index d255b63c6c..63b6b79ebd 100644 Binary files a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-voice-toast-linux.png and b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-voice-toast-linux.png differ diff --git a/apps/web/playwright/testcontainers/dendrite.ts b/apps/web/playwright/testcontainers/dendrite.ts index 68a356027e..03e34856a9 100644 --- a/apps/web/playwright/testcontainers/dendrite.ts +++ b/apps/web/playwright/testcontainers/dendrite.ts @@ -21,7 +21,7 @@ const DEFAULT_CONFIG = { global: { server_name: "localhost", private_key: "matrix_key.pem", - old_private_keys: null, + old_private_keys: null as any, key_validity_period: "168h0m0s", cache: { max_size_estimated: "1gb", @@ -47,7 +47,7 @@ const DEFAULT_CONFIG = { room_name: "Server Alerts", }, jetstream: { - addresses: null, + addresses: null as any, disable_tls_validation: false, storage_path: "./", topic_prefix: "Dendrite", @@ -67,7 +67,7 @@ const DEFAULT_CONFIG = { }, app_service_api: { disable_tls_validation: false, - config_files: null, + config_files: null as any, }, client_api: { registration_disabled: false, @@ -79,14 +79,14 @@ const DEFAULT_CONFIG = { recaptcha_bypass_secret: "", turn: { turn_user_lifetime: "5m", - turn_uris: null, + turn_uris: null as any, turn_shared_secret: "", }, rate_limiting: { enabled: true, threshold: 20, cooloff_ms: 500, - exempt_user_ids: null, + exempt_user_ids: null as any, }, }, federation_api: { @@ -140,7 +140,7 @@ const DEFAULT_CONFIG = { }, }, mscs: { - mscs: null, + mscs: null as any, database: { connection_string: "file:dendrite-msc.db", }, @@ -157,7 +157,7 @@ const DEFAULT_CONFIG = { }, user_api: { bcrypt_cost: 10, - auto_join_rooms: null, + auto_join_rooms: null as any, account_database: { connection_string: "file:dendrite-userapi.db", }, @@ -183,12 +183,12 @@ const DEFAULT_CONFIG = { serviceName: "", disabled: false, rpc_metrics: false, - tags: [], - sampler: null, - reporter: null, - headers: null, - baggage_restrictions: null, - throttler: null, + tags: [] as any[], + sampler: null as any, + reporter: null as any, + headers: null as any, + baggage_restrictions: null as any, + throttler: null as any, }, }, logging: [ diff --git a/apps/web/playwright/testcontainers/mas.ts b/apps/web/playwright/testcontainers/mas.ts index 850f22f68d..85b727189f 100644 --- a/apps/web/playwright/testcontainers/mas.ts +++ b/apps/web/playwright/testcontainers/mas.ts @@ -11,7 +11,7 @@ import { } from "@element-hq/element-web-playwright-common/lib/testcontainers/index.js"; const DOCKER_IMAGE = - "ghcr.io/element-hq/matrix-authentication-service:main@sha256:51b79f0bb1914b412bd87b5f0817820d140af84c08c22d7816ba675ee0c67150"; + "ghcr.io/element-hq/matrix-authentication-service:main@sha256:034500c4797287bfcdc4d13304e89ac65ce44a0fa33664836aea6e42c33535fb"; /** * MatrixAuthenticationServiceContainer which freezes the docker digest to diff --git a/apps/web/playwright/testcontainers/synapse.ts b/apps/web/playwright/testcontainers/synapse.ts index 20a678c2d7..5fb1c27bfd 100644 --- a/apps/web/playwright/testcontainers/synapse.ts +++ b/apps/web/playwright/testcontainers/synapse.ts @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers/index.js"; const DOCKER_IMAGE = - "ghcr.io/element-hq/synapse:develop@sha256:46e602b7b26a962eb59e9af07f4e0ebf2c09639475b96c81252ca2f39d32cd8c"; + "ghcr.io/element-hq/synapse:develop@sha256:b2fec2c9460f5b297a3a4ce78037902590240a1978301ed1d4bc97918c451041"; /** * SynapseContainer which freezes the docker digest to stabilise tests, diff --git a/apps/web/playwright/tsconfig.json b/apps/web/playwright/tsconfig.json index 968d903fa4..4f3f437a9a 100644 --- a/apps/web/playwright/tsconfig.json +++ b/apps/web/playwright/tsconfig.json @@ -2,12 +2,14 @@ "compilerOptions": { "target": "es2022", "jsx": "react", - "lib": ["ESNext", "es2022", "dom", "dom.iterable"], + "lib": ["es2024", "dom", "dom.iterable"], "resolveJsonModule": true, "esModuleInterop": true, - "moduleResolution": "node", - "module": "es2022", + "moduleResolution": "bundler", + "module": "ESNext", "allowImportingTsExtensions": true, + "strictNullChecks": false, + "noImplicitAny": false, "types": ["node"] }, "include": [ diff --git a/apps/web/project.json b/apps/web/project.json index 59fa9f3a66..abb5fc4757 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -47,11 +47,9 @@ "dependsOn": ["^build", "^build:playwright"] }, "test:unit": { - "executor": "@nx/jest:jest", - "options": { - "jestConfig": "{projectRoot}/jest.config.ts", - "cwd": "apps/web" - }, + // We avoid the jest executor because it doesn't seem to give any benefit, and it mangles the summary of failing tests. + "command": "jest", + "options": { "cwd": "apps/web" }, "dependsOn": ["^build"] }, "test:playwright": { @@ -60,7 +58,7 @@ "dependsOn": ["^build:playwright"] }, "test:playwright:screenshots": { - "command": "playwright-screenshots nx test:playwright --update-snapshots --project=Chrome --grep @screenshot", + "command": "playwright-screenshots playwright test --update-snapshots --project=Chrome --grep @screenshot", "options": { "cwd": "apps/web" }, "dependsOn": ["^build:playwright"] } diff --git a/apps/web/res/css/_common.pcss b/apps/web/res/css/_common.pcss index 4cadbe71c6..d55cb07606 100644 --- a/apps/web/res/css/_common.pcss +++ b/apps/web/res/css/_common.pcss @@ -598,6 +598,7 @@ legend { .mx_AccessSecretStorageDialog button, .mx_InviteDialog_section button, .mx_InviteDialog_editor button, + .mx_UnknownIdentityUsersWarningDialog button, [class|="maplibregl"] ), .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton), @@ -625,7 +626,8 @@ legend { .mx_ThemeChoicePanel_CustomTheme button, .mx_UnpinAllDialog button, .mx_ShareDialog button, - .mx_EncryptionUserSettingsTab button + .mx_EncryptionUserSettingsTab button, + .mx_UnknownIdentityUsersWarningDialog button ):last-child { margin-right: 0px; } @@ -641,7 +643,8 @@ legend { .mx_ShareDialog button, .mx_EncryptionUserSettingsTab button, .mx_InviteDialog_section button, - .mx_InviteDialog_editor button + .mx_InviteDialog_editor button, + .mx_UnknownIdentityUsersWarningDialog button ):focus, .mx_Dialog input[type="submit"]:focus, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton):focus, @@ -659,7 +662,8 @@ legend { .mx_ThemeChoicePanel_CustomTheme button, .mx_UnpinAllDialog button, .mx_ShareDialog button, - .mx_EncryptionUserSettingsTab button + .mx_EncryptionUserSettingsTab button, + .mx_UnknownIdentityUsersWarningDialog button ), .mx_Dialog_buttons input[type="submit"].mx_Dialog_primary { color: var(--cpd-color-text-on-solid-primary); @@ -678,7 +682,8 @@ legend { .mx_ThemeChoicePanel_CustomTheme button, .mx_UnpinAllDialog button, .mx_ShareDialog button, - .mx_EncryptionUserSettingsTab button + .mx_EncryptionUserSettingsTab button, + .mx_UnknownIdentityUsersWarningDialog button ), .mx_Dialog_buttons input[type="submit"].danger { background-color: var(--cpd-color-bg-critical-primary); @@ -701,7 +706,8 @@ legend { .mx_ThemeChoicePanel_CustomTheme button, .mx_UnpinAllDialog button, .mx_ShareDialog button, - .mx_EncryptionUserSettingsTab button + .mx_EncryptionUserSettingsTab button, + .mx_UnknownIdentityUsersWarningDialog button ):disabled, .mx_Dialog input[type="submit"]:disabled, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton):disabled, @@ -834,18 +840,6 @@ legend { } } -@define-mixin ButtonResetDefault { - appearance: none; - background: none; - border: none; - padding: 0; - margin: 0; - font-size: inherit; - font-family: inherit; - line-height: inherit; - cursor: pointer; -} - @define-mixin LegacyCallButton { box-sizing: border-box; font-weight: var(--cpd-font-weight-semibold); diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index fdf774a754..a175838d57 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -105,6 +105,7 @@ @import "./views/auth/_AuthPage.pcss"; @import "./views/auth/_CompleteSecurityBody.pcss"; @import "./views/auth/_CountryDropdown.pcss"; +@import "./views/auth/_DefaultWelcome.pcss"; @import "./views/auth/_InteractiveAuthEntryComponents.pcss"; @import "./views/auth/_LanguageSelector.pcss"; @import "./views/auth/_LoginWithQR.pcss"; @@ -128,6 +129,7 @@ @import "./views/dialogs/_ConfirmSpaceUserActionDialog.pcss"; @import "./views/dialogs/_ConfirmUserActionDialog.pcss"; @import "./views/dialogs/_CreateRoomDialog.pcss"; +@import "./views/dialogs/_CreateSectionDialog.pcss"; @import "./views/dialogs/_CreateSubspaceDialog.pcss"; @import "./views/dialogs/_Crypto.pcss"; @import "./views/dialogs/_DeactivateAccountDialog.pcss"; @@ -148,6 +150,7 @@ @import "./views/dialogs/_ModalWidgetDialog.pcss"; @import "./views/dialogs/_PollCreateDialog.pcss"; @import "./views/dialogs/_RegistrationEmailPromptDialog.pcss"; +@import "./views/dialogs/_RemoveSectionDialog.pcss"; @import "./views/dialogs/_ReportRoomDialog.pcss"; @import "./views/dialogs/_RoomSettingsDialog.pcss"; @import "./views/dialogs/_RoomSettingsDialogBridges.pcss"; @@ -169,6 +172,7 @@ @import "./views/dialogs/_UserSettingsDialog.pcss"; @import "./views/dialogs/_VerifyEMailDialog.pcss"; @import "./views/dialogs/_WidgetCapabilitiesPromptDialog.pcss"; +@import "./views/dialogs/invite/_UnknownIdentityUsersWarningDialog.pcss"; @import "./views/dialogs/security/_AccessSecretStorageDialog.pcss"; @import "./views/dialogs/security/_CreateCrossSigningDialog.pcss"; @import "./views/dialogs/security/_CreateSecretStorageDialog.pcss"; @@ -222,16 +226,13 @@ @import "./views/messages/_HiddenBody.pcss"; @import "./views/messages/_HiddenMediaPlaceholder.pcss"; @import "./views/messages/_LegacyCallEvent.pcss"; -@import "./views/messages/_MEmoteBody.pcss"; @import "./views/messages/_MFileBody.pcss"; @import "./views/messages/_MImageBody.pcss"; @import "./views/messages/_MImageReplyBody.pcss"; @import "./views/messages/_MJitsiWidgetEvent.pcss"; @import "./views/messages/_MLocationBody.pcss"; -@import "./views/messages/_MNoticeBody.pcss"; @import "./views/messages/_MPollBody.pcss"; @import "./views/messages/_MStickerBody.pcss"; -@import "./views/messages/_MTextBody.pcss"; @import "./views/messages/_MediaBody.pcss"; @import "./views/messages/_MessageActionBar.pcss"; @import "./views/messages/_MjolnirBody.pcss"; diff --git a/apps/web/res/css/components/views/location/_ShareDialogButtons.pcss b/apps/web/res/css/components/views/location/_ShareDialogButtons.pcss index d0c419accb..1c1d379f89 100644 --- a/apps/web/res/css/components/views/location/_ShareDialogButtons.pcss +++ b/apps/web/res/css/components/views/location/_ShareDialogButtons.pcss @@ -13,8 +13,7 @@ Please see LICENSE files in the repository root for full details. top: 0; } -.mx_ShareDialogButtons_button { - @mixin ButtonResetDefault; +button.mx_ShareDialogButtons_button { height: 24px; width: 24px; border-radius: 50%; diff --git a/apps/web/res/css/structures/_ToastContainer.pcss b/apps/web/res/css/structures/_ToastContainer.pcss index 6f21e495d6..5d73403be2 100644 --- a/apps/web/res/css/structures/_ToastContainer.pcss +++ b/apps/web/res/css/structures/_ToastContainer.pcss @@ -1,4 +1,5 @@ /* +Copyright 2026 Element Creations Ltd. Copyright 2024 New Vector Ltd. Copyright 2019-2021 The Matrix.org Foundation C.I.C. @@ -13,16 +14,16 @@ Please see LICENSE files in the repository root for full details. z-index: 101; padding: 4px; display: grid; - grid-template-rows: 1fr 14px 6px; + grid-template-rows: 1fr 28px 8px; &.mx_ToastContainer_stacked::before { content: ""; - margin: 0 4px; - grid-row: 2 / 4; + margin: 0 var(--cpd-space-1-5x); + grid-row: 2 / -1; grid-column: 1; background-color: $system; box-shadow: 0px 4px 20px rgb(0, 0, 0, 0.5); - border-radius: 8px; + border-radius: var(--cpd-space-6x); } .mx_Toast_toast { @@ -32,19 +33,19 @@ Please see LICENSE files in the repository root for full details. color: $primary-content; box-shadow: 0px 4px 24px rgb(0, 0, 0, 0.1); border: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-secondary); - border-radius: 12px; + border-radius: calc(var(--cpd-space-6x) - var(--cpd-border-width-1)); overflow: hidden; display: grid; - grid-template-columns: 22px 1fr; - column-gap: 8px; + grid-template-columns: 20px 1fr auto; + column-gap: var(--cpd-space-2x); row-gap: 4px; align-items: center; - padding: var(--cpd-space-3x); + padding: calc(var(--cpd-space-5x) - var(--cpd-border-width-1)); &.mx_Toast_hasIcon { svg { - width: 22px; - height: 22px; + width: 20px; + height: 20px; grid-column: 1; } @@ -52,31 +53,18 @@ Please see LICENSE files in the repository root for full details. grid-column: 2; } - .mx_Toast_body { - grid-column: 2 / 4; - } - .mx_Toast_closebutton { grid-column: 3; } } - &:not(.mx_Toast_hasIcon) { - padding-left: 12px; - - .mx_Toast_title { - grid-column: 1 / -1; - } - } - - .mx_Toast_title, - .mx_Toast_description { - padding-right: 8px; + &:not(.mx_Toast_hasIcon) .mx_Toast_title { + grid-column: 1 / -1; } .mx_Toast_title { display: flex; align-items: center; - column-gap: 8px; + column-gap: var(--cpd-space-2x); width: 100%; box-sizing: border-box; @@ -89,14 +77,14 @@ Please see LICENSE files in the repository root for full details. } .mx_Toast_body { - grid-column: 1 / 3; + grid-column: 1 / -1; grid-row: 2; } .mx_Toast_buttons { display: flex; justify-content: flex-end; - column-gap: 5px; + column-gap: var(--cpd-space-2x); .mx_AccessibleButton { min-width: 96px; @@ -108,7 +96,7 @@ Please see LICENSE files in the repository root for full details. max-width: 272px; overflow: hidden; text-overflow: ellipsis; - margin: 4px 0 11px 0; + margin: var(--cpd-space-1x) 0 11px 0; color: $secondary-content; font: var(--cpd-font-body-sm-regular); diff --git a/apps/web/res/css/views/auth/_DefaultWelcome.pcss b/apps/web/res/css/views/auth/_DefaultWelcome.pcss new file mode 100644 index 0000000000..08183e77b1 --- /dev/null +++ b/apps/web/res/css/views/auth/_DefaultWelcome.pcss @@ -0,0 +1,43 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +.mx_DefaultWelcome { + text-align: center; + + .mx_DefaultWelcome_logo img { + height: 48px; + aspect-ratio: auto; + display: block; + margin: 0 auto; + } + + h1 { + margin: var(--cpd-space-4x) 0 var(--cpd-space-2x); + } + + p { + color: var(--cpd-color-text-secondary); + margin-top: var(--cpd-space-2x); + } + + .mx_DefaultWelcome_buttons { + margin: var(--cpd-space-6x) 0 var(--cpd-space-1x); + padding-bottom: var(--cpd-space-4x); + border-bottom: 1px solid var(--cpd-color-separator-primary); + + a { + width: 380px; + margin-bottom: var(--cpd-space-4x); + } + } +} + +.mx_WelcomePage_registrationDisabled { + .mx_DefaultWelcome_buttons_register { + display: none; + } +} diff --git a/apps/web/res/css/views/auth/_LoginWithQR.pcss b/apps/web/res/css/views/auth/_LoginWithQR.pcss index e4e41d496e..82703e08e2 100644 --- a/apps/web/res/css/views/auth/_LoginWithQR.pcss +++ b/apps/web/res/css/views/auth/_LoginWithQR.pcss @@ -34,45 +34,45 @@ Please see LICENSE files in the repository root for full details. font-size: $font-15px; } -.mx_UserSettingsDialog .mx_LoginWithQR { +.mx_LoginWithQR { + min-height: 350px; + display: flex; + flex-direction: column; font: var(--cpd-font-body-md-regular); h1 { font-size: $font-24px; margin-bottom: 0; + + svg { + &.normal { + color: $secondary-content; + } + &.error { + color: $alert; + } + &.success { + color: $accent; + } + height: 1.3em; + margin-right: $spacing-8; + vertical-align: middle; + } } h2 { margin-top: $spacing-24; } - .mx_QRCode { - margin: $spacing-28 0; - } - .mx_LoginWithQR_qrWrapper { display: flex; - } -} + padding: $spacing-28 0; -.mx_LoginWithQR { - min-height: 350px; - display: flex; - flex-direction: column; - - h1 > svg { - &.normal { - color: $secondary-content; + .mx_Spinner { + /* Match the size of the QR code to prevent jumps */ + height: 200px; + width: 200px; } - &.error { - color: $alert; - } - &.success { - color: $accent; - } - height: 1.3em; - margin-right: $spacing-8; - vertical-align: middle; } .mx_LoginWithQR_confirmationDigits { diff --git a/apps/web/res/css/views/auth/_Welcome.pcss b/apps/web/res/css/views/auth/_Welcome.pcss index 50a91aa767..12598f3293 100644 --- a/apps/web/res/css/views/auth/_Welcome.pcss +++ b/apps/web/res/css/views/auth/_Welcome.pcss @@ -9,6 +9,10 @@ Please see LICENSE files in the repository root for full details. display: flex; flex-direction: column; align-items: center; + background-color: var(--cpd-color-bg-canvas-default); + box-sizing: border-box; + padding: var(--cpd-space-11x) var(--cpd-space-12x) var(--cpd-space-4x); + &.mx_WelcomePage_registrationDisabled { .mx_ButtonCreateAccount { display: none; @@ -18,7 +22,7 @@ Please see LICENSE files in the repository root for full details. .mx_Welcome .mx_AuthBody_language { width: 160px; - margin-bottom: 10px; + margin: var(--cpd-space-1x) 0; } /* Invert image colours in dark mode. */ diff --git a/apps/web/res/css/views/dialogs/_CreateSectionDialog.pcss b/apps/web/res/css/views/dialogs/_CreateSectionDialog.pcss new file mode 100644 index 0000000000..7c941be39f --- /dev/null +++ b/apps/web/res/css/views/dialogs/_CreateSectionDialog.pcss @@ -0,0 +1,23 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.mx_CreateSectionDialog { + color: var(--cpd-color-text-primary); + + &.mx_Dialog_fixedWidth { + /* 576px coming from Figma and remove external padding */ + max-width: calc(576px - var(--cpd-space-20x)); + } + + .mx_CreateSectionDialog_content { + min-height: 346px; + } + + .mx_CreateSectionDialog_form { + width: 100%; + } +} diff --git a/apps/web/res/css/views/dialogs/_MessageEditHistoryDialog.pcss b/apps/web/res/css/views/dialogs/_MessageEditHistoryDialog.pcss index d14bb3ca60..5f136e9927 100644 --- a/apps/web/res/css/views/dialogs/_MessageEditHistoryDialog.pcss +++ b/apps/web/res/css/views/dialogs/_MessageEditHistoryDialog.pcss @@ -27,6 +27,10 @@ Please see LICENSE files in the repository root for full details. padding: 0; color: $primary-content; + .mx_EditHistoryMessage_emoteSender { + cursor: pointer; + } + span.mx_EditHistoryMessage_deletion, span.mx_EditHistoryMessage_insertion { padding: 0px 2px; diff --git a/apps/web/res/css/views/dialogs/_RemoveSectionDialog.pcss b/apps/web/res/css/views/dialogs/_RemoveSectionDialog.pcss new file mode 100644 index 0000000000..30fe63cc9a --- /dev/null +++ b/apps/web/res/css/views/dialogs/_RemoveSectionDialog.pcss @@ -0,0 +1,10 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.mx_RemoveSectionDialog { + color: var(--cpd-color-text-primary); +} diff --git a/apps/web/res/css/views/dialogs/invite/_UnknownIdentityUsersWarningDialog.pcss b/apps/web/res/css/views/dialogs/invite/_UnknownIdentityUsersWarningDialog.pcss new file mode 100644 index 0000000000..085d9dfa5d --- /dev/null +++ b/apps/web/res/css/views/dialogs/invite/_UnknownIdentityUsersWarningDialog.pcss @@ -0,0 +1,45 @@ +/* + Copyright 2026 Element Creations Ltd. + + SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + Please see LICENSE files in the repository root for full details. +*/ + +.mx_UnknownIdentityUsersWarningDialog { + display: flex; + flex-direction: column; + height: 600px; /* Consistency with InviteDialog */ +} + +.mx_UnknownIdentityUsersWarningDialog_headerContainer { + /* Centre the PageHeader component horizontally */ + display: flex; + justify-content: center; + + /* Styling for the regular text inside the header */ + font: var(--cpd-font-body-lg-regular); + + /* Space before the list */ + padding-bottom: var(--cpd-space-6x); +} + +.mx_UnknownIdentityUsersWarningDialog_userList { + width: 100%; + overflow: auto; + + /* Fill available vertical space, but don't allow it to shrink to less than 60px (about the height of a single tile) */ + flex: 1 0 60px; + + /* Remove browser default ul padding/margin */ + padding: 0; + margin: 0; +} + +.mx_UnknownIdentityUsersWarningDialog_buttons { + display: flex; + gap: var(--cpd-space-4x); + + > button { + flex: 1; + } +} diff --git a/apps/web/res/css/views/elements/_AccessibleButton.pcss b/apps/web/res/css/views/elements/_AccessibleButton.pcss index fafe75c642..e79a858d49 100644 --- a/apps/web/res/css/views/elements/_AccessibleButton.pcss +++ b/apps/web/res/css/views/elements/_AccessibleButton.pcss @@ -9,6 +9,19 @@ Please see LICENSE files in the repository root for full details. .mx_AccessibleButton { cursor: pointer; + &:where(button) { + /* Clear default button styling */ + appearance: none; + background: none; + border: none; + padding: 0; + margin: 0; + font-size: inherit; + font-family: inherit; + line-height: inherit; + box-sizing: content-box; + } + &.mx_AccessibleButton_disabled { cursor: not-allowed; diff --git a/apps/web/res/css/views/elements/_CopyableText.pcss b/apps/web/res/css/views/elements/_CopyableText.pcss index 6219055085..e5cf1c51da 100644 --- a/apps/web/res/css/views/elements/_CopyableText.pcss +++ b/apps/web/res/css/views/elements/_CopyableText.pcss @@ -28,7 +28,6 @@ Please see LICENSE files in the repository root for full details. /* using em here to adapt to the local font size */ width: 1em; height: 1em; - cursor: pointer; padding-left: 12px; padding-right: 10px; display: block; diff --git a/apps/web/res/css/views/messages/_MEmoteBody.pcss b/apps/web/res/css/views/messages/_MEmoteBody.pcss deleted file mode 100644 index ad7abb0176..0000000000 --- a/apps/web/res/css/views/messages/_MEmoteBody.pcss +++ /dev/null @@ -1,16 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2017 Vector Creations Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_MEmoteBody { - white-space: pre-wrap; - text-align: start; -} - -.mx_MEmoteBody_sender { - cursor: pointer; -} diff --git a/apps/web/res/css/views/messages/_MNoticeBody.pcss b/apps/web/res/css/views/messages/_MNoticeBody.pcss deleted file mode 100644 index f82c2b8fcc..0000000000 --- a/apps/web/res/css/views/messages/_MNoticeBody.pcss +++ /dev/null @@ -1,12 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2015, 2016 OpenMarket Ltd - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_MNoticeBody { - white-space: pre-wrap; - color: $secondary-content; -} diff --git a/apps/web/res/css/views/rooms/_EventTile.pcss b/apps/web/res/css/views/rooms/_EventTile.pcss index 63d1bdaee2..ffe0fcc13d 100644 --- a/apps/web/res/css/views/rooms/_EventTile.pcss +++ b/apps/web/res/css/views/rooms/_EventTile.pcss @@ -636,19 +636,6 @@ $left-gutter: 64px; overflow-x: hidden; margin-right: var(--EventTile_content-margin-inline-end); - .mx_EventTile_edited, - .mx_EventTile_pendingModeration { - user-select: none; - font-size: $font-12px; - color: $secondary-content; - display: inline-block; - margin-inline-start: 9px; - } - - .mx_EventTile_edited { - cursor: pointer; - } - .markdown-body { font: var(--cpd-font-body-md-regular) !important; letter-spacing: var(--cpd-font-letter-spacing-body-md); @@ -1374,14 +1361,6 @@ $left-gutter: 64px; } } -.mx_EventTile_annotated { - display: flex; -} - -.mx_EventTile_annotatedInline { - display: inline-flex; -} - .mx_EventTile_footer { display: flex; gap: var(--cpd-space-2x); diff --git a/apps/web/res/css/views/rooms/_ReplyTile.pcss b/apps/web/res/css/views/rooms/_ReplyTile.pcss index 64492762ad..aea4bae626 100644 --- a/apps/web/res/css/views/rooms/_ReplyTile.pcss +++ b/apps/web/res/css/views/rooms/_ReplyTile.pcss @@ -68,7 +68,7 @@ Please see LICENSE files in the repository root for full details. // Hide line numbers and edited indicator .mx_EventTile_lineNumbers, - .mx_EventTile_edited { + [data-textual-body-edited-marker] { display: none; } diff --git a/apps/web/res/css/views/settings/_NotificationSettings2.pcss b/apps/web/res/css/views/settings/_NotificationSettings2.pcss index db439f0706..8a41322615 100644 --- a/apps/web/res/css/views/settings/_NotificationSettings2.pcss +++ b/apps/web/res/css/views/settings/_NotificationSettings2.pcss @@ -12,12 +12,6 @@ Please see LICENSE files in the repository root for full details. gap: 32px; display: flex; flex-direction: column; - - > form { - gap: 32px; - display: flex; - flex-direction: column; - } } .mx_SettingsSubsection_description { diff --git a/apps/web/res/css/views/settings/tabs/_SettingsTab.pcss b/apps/web/res/css/views/settings/tabs/_SettingsTab.pcss index 92a392950f..c92e454e05 100644 --- a/apps/web/res/css/views/settings/tabs/_SettingsTab.pcss +++ b/apps/web/res/css/views/settings/tabs/_SettingsTab.pcss @@ -14,12 +14,13 @@ Please see LICENSE files in the repository root for full details. color: $links; } - form:not(.mx_EncryptionUserSettingsTab form) { + form { display: flex; flex-direction: column; - gap: $spacing-8; + gap: var(--cpd-space-3x); flex-grow: 1; } + // never want full width buttons // event when other content is 100% width .mx_AccessibleButton { diff --git a/apps/web/res/css/views/toasts/_IncomingCallToast.pcss b/apps/web/res/css/views/toasts/_IncomingCallToast.pcss index 95359a5fad..428f0884dc 100644 --- a/apps/web/res/css/views/toasts/_IncomingCallToast.pcss +++ b/apps/web/res/css/views/toasts/_IncomingCallToast.pcss @@ -9,64 +9,55 @@ Please see LICENSE files in the repository root for full details. .mx_IncomingCallToast { position: relative; display: flex; - flex-direction: row; + flex-direction: column; pointer-events: initial; /* restore pointer events so the user can accept/decline */ - $closeButtonSize: var(--cpd-space-4x); - .mx_IncomingCallToast_content { display: flex; flex-direction: column; gap: var(--cpd-space-4x); - padding: var(--cpd-space-3x); width: 100%; overflow: hidden; - .mx_IncomingCallToast_message { - font-size: var(--cpd-font-size-body-lg); - line-height: var(--cpd-font-size-heading-sm); - width: calc(100% - $closeButtonSize - 2 * var(--cpd-space-1x)); - font-weight: var(--cpd-font-weight-semibold); + .mx_IncomingCallToast_title { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: var(--cpd-space-2x); + + h2 { + margin: 0; + } + + .mx_IncomingCallToast_expandButton { + padding: var(--cpd-space-1x); + color: var(--cpd-color-icon-secondary); + transition: color 0.1s; + + &:hover { + color: var(--cpd-color-icon-primary); + } + + & > svg { + display: block; + } + } + } + + .mx_IncomingCallToast_avatars { + display: inline-block; + vertical-align: top; } .mx_IncomingCallToast_buttons { display: flex; gap: var(--cpd-space-2x); + padding-block-start: var(--cpd-space-2x); } .mx_IncomingCallToast_actionButton { - position: relative; - - align-self: flex-end; - box-sizing: border-box; - min-width: 120px; - - padding: var(--cpd-space-1x) 0; - padding-right: var(--cpd-space-4x); - line-height: var(--cpd-space-6x); - } - } - - .mx_IncomingCallToast_closeButton { - position: absolute; - - right: 0; - - display: flex; - height: $closeButtonSize; - width: $closeButtonSize; - - svg { - height: inherit; - width: inherit; - color: $secondary-content; - } - } - .mx_IncomingCallToast_toggleWithLabel { - display: flex; - span { - flex-grow: 1; + min-width: 131px; } } } diff --git a/apps/web/res/welcome.html b/apps/web/res/welcome.html deleted file mode 100644 index f1cf3911f4..0000000000 --- a/apps/web/res/welcome.html +++ /dev/null @@ -1,191 +0,0 @@ - - -
- - - -

_t("welcome_to_element")

- -

_t("powered_by_matrix_with_logo")

- -
diff --git a/apps/web/res/welcome/images/icon-create-account.svg b/apps/web/res/welcome/images/icon-create-account.svg deleted file mode 100644 index 7bbef7f632..0000000000 --- a/apps/web/res/welcome/images/icon-create-account.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/web/res/welcome/images/icon-help.svg b/apps/web/res/welcome/images/icon-help.svg deleted file mode 100644 index dc96f8e0cf..0000000000 --- a/apps/web/res/welcome/images/icon-help.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/apps/web/res/welcome/images/icon-room-directory.svg b/apps/web/res/welcome/images/icon-room-directory.svg deleted file mode 100644 index 3786ce1153..0000000000 --- a/apps/web/res/welcome/images/icon-room-directory.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/web/res/welcome/images/icon-sign-in.svg b/apps/web/res/welcome/images/icon-sign-in.svg deleted file mode 100644 index 9bc2fefa3f..0000000000 --- a/apps/web/res/welcome/images/icon-sign-in.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/web/src/@types/common.ts b/apps/web/src/@types/common.ts index 4c8c707c4a..bfb4f64c16 100644 --- a/apps/web/src/@types/common.ts +++ b/apps/web/src/@types/common.ts @@ -52,3 +52,13 @@ export type AtLeastOne }> = Partial & U[k export type Assignable = { [Key in keyof Object]: Object[Key] extends Item ? Key : never; }[keyof Object]; + +/** + * Like `Partial` but for applied to all nested objects. + * Based on https://dev.to/perennialautodidact/adventures-in-typescript-deeppartial-2f2a + */ +export type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; diff --git a/apps/web/res/css/views/messages/_MTextBody.pcss b/apps/web/src/@types/css.d.ts similarity index 59% rename from apps/web/res/css/views/messages/_MTextBody.pcss rename to apps/web/src/@types/css.d.ts index 973fd3a354..a37139aa72 100644 --- a/apps/web/res/css/views/messages/_MTextBody.pcss +++ b/apps/web/src/@types/css.d.ts @@ -1,11 +1,8 @@ /* -Copyright 2024 New Vector Ltd. -Copyright 2015, 2016 OpenMarket Ltd +Copyright 2026 Element Creations Ltd. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ -.mx_MTextBody { - white-space: pre-wrap; -} +declare module "*.css"; diff --git a/apps/web/src/@types/global.d.ts b/apps/web/src/@types/global.d.ts index f6b9ff81f9..99cd6d4ba4 100644 --- a/apps/web/src/@types/global.d.ts +++ b/apps/web/src/@types/global.d.ts @@ -8,7 +8,6 @@ Please see LICENSE files in the repository root for full details. // eslint-disable-next-line no-restricted-imports import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first -import "@types/modernizr"; import type { ModuleLoader } from "@element-hq/element-web-module-api"; import type { logger } from "matrix-js-sdk/src/logger"; @@ -186,14 +185,6 @@ declare global { readonly port: MessagePort; } - /** - * In future, browsers will support focusVisible option. - * See https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus#focusvisible - */ - interface FocusOptions { - focusVisible: boolean; - } - // https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278 function registerProcessor( name: string, diff --git a/apps/web/src/accessibility/roving/types.ts b/apps/web/src/@types/pcss.d.ts similarity index 57% rename from apps/web/src/accessibility/roving/types.ts rename to apps/web/src/@types/pcss.d.ts index f06c15cf7a..64680d80cf 100644 --- a/apps/web/src/accessibility/roving/types.ts +++ b/apps/web/src/@types/pcss.d.ts @@ -1,9 +1,8 @@ /* -Copyright 2024 New Vector Ltd. -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2026 Element Creations Ltd. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ -export type FocusHandler = () => void; +declare module "*.pcss"; diff --git a/apps/web/src/IConfigOptions.ts b/apps/web/src/IConfigOptions.ts index fd39e2f8df..a3a6a32c91 100644 --- a/apps/web/src/IConfigOptions.ts +++ b/apps/web/src/IConfigOptions.ts @@ -52,8 +52,9 @@ export interface IConfigOptions { disable_3pid_login?: boolean; brand: string; - branding?: { - welcome_background_url?: string | string[]; // chosen at random if array + branding: { + welcome_background_url: string | string[]; // chosen at random if array + logo_link_url: string; auth_header_logo_url?: string; auth_footer_links?: { text: string; url: string }[]; }; diff --git a/apps/web/src/Lifecycle.ts b/apps/web/src/Lifecycle.ts index d7d139249a..32d113adc8 100644 --- a/apps/web/src/Lifecycle.ts +++ b/apps/web/src/Lifecycle.ts @@ -277,9 +277,10 @@ export async function attemptDelegatedAuthLogin( defaultDeviceDisplayName?: string, fragmentAfterLogin?: string, ): Promise { - if (urlParams.oidc) { - console.log("We have OIDC params - attempting OIDC login"); - return attemptOidcNativeLogin(urlParams["oidc"]); + if (urlParams.oidc_fragment) { + return attemptOidcNativeLogin(urlParams.oidc_fragment, "fragment"); + } else if (urlParams.oidc_query) { + return attemptOidcNativeLogin(urlParams.oidc_query, "query"); } return attemptTokenLogin(urlParams["legacy_sso"], defaultDeviceDisplayName, fragmentAfterLogin); @@ -288,12 +289,18 @@ export async function attemptDelegatedAuthLogin( /** * Attempt to login by completing OIDC authorization code flow * @param urlParams subset of app-load url parameters relating to oidc auth + * @param responseMode - the response_mode used in the auth request * @returns Promise that resolves to true when login succeeded, else false */ -async function attemptOidcNativeLogin(urlParams: NonNullable): Promise { +async function attemptOidcNativeLogin( + urlParams: NonNullable, + responseMode: "fragment" | "query", +): Promise { + console.log("We have OIDC params - attempting OIDC login"); + try { const { accessToken, refreshToken, homeserverUrl, identityServerUrl, idToken, clientId, issuer } = - await completeOidcLogin(urlParams); + await completeOidcLogin(urlParams, responseMode); const { user_id: userId, @@ -1036,7 +1043,7 @@ export function isLoggingOut(): boolean { * By the time this method is called, we have successfully logged in if necessary, and the client has been set up with * the access token. * - * Emits {@link Acction.WillStartClient} before starting the client, and {@link Action.ClientStarted} when the client has + * Emits {@link Action.WillStartClient} before starting the client, and {@link Action.ClientStarted} when the client has * been started. * * @param client the matrix client to start diff --git a/apps/web/src/PosthogTrackers.ts b/apps/web/src/PosthogTrackers.ts index cc531bde9b..19bb900b7c 100644 --- a/apps/web/src/PosthogTrackers.ts +++ b/apps/web/src/PosthogTrackers.ts @@ -13,7 +13,6 @@ import { type Interaction as InteractionEvent } from "@matrix-org/analytics-even import { type PinUnpinAction } from "@matrix-org/analytics-events/types/typescript/PinUnpinAction"; import { type RoomListSortingAlgorithmChanged } from "@matrix-org/analytics-events/types/typescript/RoomListSortingAlgorithmChanged"; import { type UrlPreviewRendered } from "@matrix-org/analytics-events/types/typescript/UrlPreviewRendered"; -import { type UrlPreview } from "@element-hq/web-shared-components"; import PageType from "./PageTypes"; import Views from "./Views"; @@ -151,7 +150,7 @@ export default class PosthogTrackers { * @param isEncrypted Whether the event (and effectively the room) was encrypted. * @param previews The previews generated from the event. */ - public trackUrlPreview(eventId: string, isEncrypted: boolean, previews: UrlPreview[]): void { + public trackUrlPreview(eventId: string, isEncrypted: boolean, previews: { image?: unknown }[]): void { // Discount any previews that we have already tracked. if (this.previewedEventIds.get(eventId)) { return; diff --git a/apps/web/src/SdkConfig.ts b/apps/web/src/SdkConfig.ts index 1a26e4f815..4be64648d8 100644 --- a/apps/web/src/SdkConfig.ts +++ b/apps/web/src/SdkConfig.ts @@ -12,11 +12,16 @@ import { mergeWith } from "lodash"; import { SnakedObject } from "./utils/SnakedObject"; import { type IConfigOptions } from "./IConfigOptions"; import { isObject, objectClone } from "./utils/objects"; -import { type DeepReadonly, type Defaultize } from "./@types/common"; +import { type DeepPartial, type DeepReadonly, type Defaultize } from "./@types/common"; // see element-web config.md for docs, or the IConfigOptions interface for dev docs export const DEFAULTS: DeepReadonly = { brand: "Element", + branding: { + logo_link_url: "https://element.io", + auth_header_logo_url: "themes/element/img/logos/element-logo.svg", + welcome_background_url: "themes/element/img/backgrounds/lake.jpg", + }, help_url: "https://element.io/help", help_encryption_url: "https://element.io/help#encryption", help_key_storage_url: "https://element.io/help#encryption5", @@ -70,7 +75,7 @@ export type ConfigOptions = Defaultize; function mergeConfig( config: DeepReadonly, - changes: DeepReadonly>, + changes: DeepReadonly>, ): DeepReadonly { // return { ...config, ...changes }; return mergeWith(objectClone(config), changes, (objValue, srcValue) => { @@ -136,7 +141,7 @@ export default class SdkConfig { SdkConfig.setInstance(mergeConfig(DEFAULTS, {})); // safe to cast - defaults will be applied } - public static add(cfg: Partial): void { + public static add(cfg: DeepPartial): void { SdkConfig.put(mergeConfig(SdkConfig.get(), cfg)); } } diff --git a/apps/web/src/accessibility/RovingTabIndex.tsx b/apps/web/src/accessibility/RovingTabIndex.tsx index 947024661e..040b6d27be 100644 --- a/apps/web/src/accessibility/RovingTabIndex.tsx +++ b/apps/web/src/accessibility/RovingTabIndex.tsx @@ -6,23 +6,21 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { - createContext, - useCallback, - useContext, - useMemo, - useRef, - useReducer, - type Reducer, - type Dispatch, - type RefObject, - type ReactNode, - type RefCallback, -} from "react"; +import React from "react"; +import { + RovingAction, + RovingTabIndexProvider as SharedRovingTabIndexProvider, + type RovingTabIndexProviderProps, +} from "@element-hq/web-shared-components"; import { getKeyBindingsManager } from "../KeyBindingsManager"; import { KeyBindingAction } from "./KeyboardShortcuts"; -import { type FocusHandler } from "./roving/types"; + +export { findNextSiblingElement, RovingTabIndexContext } from "@element-hq/web-shared-components"; +export { checkInputableElement } from "@element-hq/web-shared-components"; +export { RovingStateActionType } from "@element-hq/web-shared-components"; +export { useRovingTabIndex } from "@element-hq/web-shared-components"; +export type { IAction, IState } from "@element-hq/web-shared-components"; /** * Module to simplify implementing the Roving TabIndex accessibility technique @@ -37,370 +35,31 @@ import { type FocusHandler } from "./roving/types"; * https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex */ -// Check for form elements which utilize the arrow keys for native functions -// like many of the text input varieties. -// -// i.e. it's ok to press the down arrow on a radio button to move to the next -// radio. But it's not ok to press the down arrow on a to -// move away because the down arrow should move the cursor to the end of the -// input. -export function checkInputableElement(el: HTMLElement): boolean { - return el.matches('input:not([type="radio"]):not([type="checkbox"]), textarea, select, [contenteditable=true]'); -} - -export interface IState { - activeNode?: HTMLElement; - nodes: HTMLElement[]; -} - -export interface IContext { - state: IState; - dispatch: Dispatch; -} - -export const RovingTabIndexContext = createContext({ - state: { - nodes: [], // list of nodes in DOM order - }, - dispatch: () => {}, -}); -RovingTabIndexContext.displayName = "RovingTabIndexContext"; - -export enum Type { - Register = "REGISTER", - Unregister = "UNREGISTER", - SetFocus = "SET_FOCUS", - Update = "UPDATE", -} - -export interface IAction { - type: Exclude; - payload: { - node: HTMLElement; - }; -} - -interface UpdateAction { - type: Type.Update; - payload?: undefined; -} - -type Action = IAction | UpdateAction; - -const nodeSorter = (a: HTMLElement, b: HTMLElement): number => { - if (a === b) { - return 0; - } - - const position = a.compareDocumentPosition(b); - - if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) { - return -1; - } else if (position & Node.DOCUMENT_POSITION_PRECEDING || position & Node.DOCUMENT_POSITION_CONTAINS) { - return 1; - } else { - return 0; - } -}; - -export const reducer: Reducer = (state: IState, action: Action) => { - switch (action.type) { - case Type.Register: { - if (!state.activeNode) { - // Our list of nodes was empty, set activeNode to this first item - state.activeNode = action.payload.node; - } - - if (state.nodes.includes(action.payload.node)) return state; - - // Sadly due to the potential of DOM elements swapping order we can't do anything fancy like a binary insert - state.nodes.push(action.payload.node); - state.nodes.sort(nodeSorter); - - return { ...state }; - } - - case Type.Unregister: { - const oldIndex = state.nodes.findIndex((r) => r === action.payload.node); - - if (oldIndex === -1) { - return state; // already removed, this should not happen - } - - if (state.nodes.splice(oldIndex, 1)[0] === state.activeNode) { - // we just removed the active node, need to replace it - // pick the node closest to the index the old node was in - if (oldIndex >= state.nodes.length) { - state.activeNode = findSiblingElement(state.nodes, state.nodes.length - 1, true); - } else { - state.activeNode = - findSiblingElement(state.nodes, oldIndex) || findSiblingElement(state.nodes, oldIndex, true); - } - if (document.activeElement === document.body) { - // if the focus got reverted to the body then the user was likely focused on the unmounted element - setTimeout(() => state.activeNode?.focus(), 0); - } - } - - // update the nodes list - return { ...state }; - } - - case Type.SetFocus: { - // if the node doesn't change just return the same object reference to skip a re-render - if (state.activeNode === action.payload.node) return state; - // update active node - state.activeNode = action.payload.node; - return { ...state }; - } - - case Type.Update: { - state.nodes.sort(nodeSorter); - return { ...state }; - } - +const getWebRovingAction = (ev: React.KeyboardEvent): RovingAction | undefined => { + switch (getKeyBindingsManager().getAccessibilityAction(ev)) { + case KeyBindingAction.Home: + return RovingAction.Home; + case KeyBindingAction.End: + return RovingAction.End; + case KeyBindingAction.ArrowLeft: + return RovingAction.ArrowLeft; + case KeyBindingAction.ArrowUp: + return RovingAction.ArrowUp; + case KeyBindingAction.ArrowRight: + return RovingAction.ArrowRight; + case KeyBindingAction.ArrowDown: + return RovingAction.ArrowDown; + case KeyBindingAction.Tab: + return RovingAction.Tab; default: - return state; + return undefined; } }; -interface IProps { - handleLoop?: boolean; - handleHomeEnd?: boolean; - handleUpDown?: boolean; - handleLeftRight?: boolean; - handleInputFields?: boolean; - scrollIntoView?: boolean | ScrollIntoViewOptions; - children( - this: void, - renderProps: { - onKeyDownHandler(this: void, ev: React.KeyboardEvent): void; - onDragEndHandler(this: void): void; - }, - ): ReactNode; - onKeyDown?(this: void, ev: React.KeyboardEvent, state: IState, dispatch: Dispatch): void; -} +type IProps = Omit; -export const findSiblingElement = ( - nodes: HTMLElement[], - startIndex: number, - backwards = false, - loop = false, -): HTMLElement | undefined => { - if (backwards) { - for (let i = startIndex; i < nodes.length && i >= 0; i--) { - if (nodes[i]?.offsetParent !== null) { - return nodes[i]; - } - } - if (loop) { - return findSiblingElement(nodes.slice(startIndex + 1), nodes.length - 1, true, false); - } - } else { - for (let i = startIndex; i < nodes.length && i >= 0; i++) { - if (nodes[i]?.offsetParent !== null) { - return nodes[i]; - } - } - if (loop) { - return findSiblingElement(nodes.slice(0, startIndex), 0, false, false); - } - } -}; - -export const RovingTabIndexProvider: React.FC = ({ - children, - handleHomeEnd, - handleUpDown, - handleLeftRight, - handleLoop, - handleInputFields, - scrollIntoView, - onKeyDown, -}) => { - const [state, dispatch] = useReducer(reducer, { - nodes: [], - }); - - const context = useMemo(() => ({ state, dispatch }), [state]); - - const onKeyDownHandler = useCallback( - (ev: React.KeyboardEvent) => { - if (onKeyDown) { - onKeyDown(ev, context.state, context.dispatch); - if (ev.defaultPrevented) { - return; - } - } - - let handled = false; - const action = getKeyBindingsManager().getAccessibilityAction(ev); - let focusNode: HTMLElement | undefined; - // Don't interfere with input default keydown behaviour - // but allow people to move focus from it with Tab. - if (!handleInputFields && checkInputableElement(ev.target as HTMLElement)) { - switch (action) { - case KeyBindingAction.Tab: - handled = true; - if (context.state.nodes.length > 0) { - const idx = context.state.nodes.indexOf(context.state.activeNode!); - focusNode = findSiblingElement( - context.state.nodes, - idx + (ev.shiftKey ? -1 : 1), - ev.shiftKey, - ); - } - break; - } - } else { - // check if we actually have any items - switch (action) { - case KeyBindingAction.Home: - if (handleHomeEnd) { - handled = true; - // move focus to first (visible) item - focusNode = findSiblingElement(context.state.nodes, 0); - } - break; - - case KeyBindingAction.End: - if (handleHomeEnd) { - handled = true; - // move focus to last (visible) item - focusNode = findSiblingElement(context.state.nodes, context.state.nodes.length - 1, true); - } - break; - - case KeyBindingAction.ArrowDown: - case KeyBindingAction.ArrowRight: - if ( - (action === KeyBindingAction.ArrowDown && handleUpDown) || - (action === KeyBindingAction.ArrowRight && handleLeftRight) - ) { - handled = true; - if (context.state.nodes.length > 0) { - const idx = context.state.nodes.indexOf(context.state.activeNode!); - focusNode = findSiblingElement(context.state.nodes, idx + 1, false, handleLoop); - } - } - break; - - case KeyBindingAction.ArrowUp: - case KeyBindingAction.ArrowLeft: - if ( - (action === KeyBindingAction.ArrowUp && handleUpDown) || - (action === KeyBindingAction.ArrowLeft && handleLeftRight) - ) { - handled = true; - if (context.state.nodes.length > 0) { - const idx = context.state.nodes.indexOf(context.state.activeNode!); - focusNode = findSiblingElement(context.state.nodes, idx - 1, true, handleLoop); - } - } - break; - } - } - - if (handled) { - ev.preventDefault(); - ev.stopPropagation(); - } - - if (focusNode) { - focusNode?.focus(); - // programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves - dispatch({ - type: Type.SetFocus, - payload: { - node: focusNode, - }, - }); - if (scrollIntoView) { - focusNode?.scrollIntoView(scrollIntoView); - } - } - }, - [ - context, - onKeyDown, - handleHomeEnd, - handleUpDown, - handleLeftRight, - handleLoop, - handleInputFields, - scrollIntoView, - ], - ); - - const onDragEndHandler = useCallback(() => { - dispatch({ - type: Type.Update, - }); - }, []); - - return ( - - {children({ onKeyDownHandler, onDragEndHandler })} - - ); -}; - -/** - * Hook to register a roving tab index. - * - * inputRef is an optional argument; when passed this ref points to the DOM element - * to which the callback ref is attached. - * - * Returns: - * onFocus should be called when the index gained focus in any manner. - * isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`. - * ref is a callback ref that should be passed to a DOM node which will be used for DOM compareDocumentPosition. - * nodeRef is a ref that points to the DOM element to which the ref mentioned above is attached. - * - * nodeRef = inputRef when inputRef argument is provided. - */ -export const useRovingTabIndex = ( - inputRef?: RefObject, -): [FocusHandler, boolean, RefCallback, RefObject] => { - const context = useContext(RovingTabIndexContext); - - let nodeRef = useRef(null); - - if (inputRef) { - // if we are given a ref, use it instead of ours - nodeRef = inputRef; - } - - const ref = useCallback((node: T | null) => { - if (node) { - nodeRef.current = node; - context.dispatch({ - type: Type.Register, - payload: { node }, - }); - } else { - context.dispatch({ - type: Type.Unregister, - payload: { node: nodeRef.current! }, - }); - nodeRef.current = null; - } - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - const onFocus = useCallback(() => { - if (!nodeRef.current) { - console.warn("useRovingTabIndex.onFocus called but the react ref does not point to any DOM element!"); - return; - } - context.dispatch({ - type: Type.SetFocus, - payload: { node: nodeRef.current }, - }); - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - // eslint-disable-next-line react-compiler/react-compiler - const isActive = context.state.activeNode === nodeRef.current; - return [onFocus, isActive, ref, nodeRef]; +export const RovingTabIndexProvider: React.FC = (props) => { + return ; }; // re-export the semantic helper components for simplicity diff --git a/apps/web/src/accessibility/roving/RovingTabIndexWrapper.tsx b/apps/web/src/accessibility/roving/RovingTabIndexWrapper.tsx index 5d2f001241..b7f7a2f9a3 100644 --- a/apps/web/src/accessibility/roving/RovingTabIndexWrapper.tsx +++ b/apps/web/src/accessibility/roving/RovingTabIndexWrapper.tsx @@ -6,26 +6,4 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { type ReactElement, type RefCallback, type RefObject } from "react"; - -import type React from "react"; -import { useRovingTabIndex } from "../RovingTabIndex"; -import { type FocusHandler } from "./types"; - -interface IProps { - inputRef?: RefObject; - children( - this: void, - renderProps: { - onFocus: FocusHandler; - isActive: boolean; - ref: RefCallback; - }, - ): ReactElement; -} - -// Wrapper to allow use of useRovingTabIndex outside of React Functional Components. -export const RovingTabIndexWrapper: React.FC = ({ children, inputRef }) => { - const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); - return children({ onFocus, isActive, ref }); -}; +export { RovingTabIndexWrapper } from "@element-hq/web-shared-components"; diff --git a/apps/web/src/async-components/structures/ErrorView.tsx b/apps/web/src/async-components/structures/ErrorView.tsx index 7271bac0cf..4d2a8f7a22 100644 --- a/apps/web/src/async-components/structures/ErrorView.tsx +++ b/apps/web/src/async-components/structures/ErrorView.tsx @@ -192,11 +192,11 @@ export const UnsupportedBrowserView: React.FC<{ - {onAccept && ( - )} diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts new file mode 100644 index 0000000000..fd231479e6 --- /dev/null +++ b/apps/web/src/branding.ts @@ -0,0 +1,20 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import SdkConfig from "./SdkConfig.ts"; + +const ELEMENT_BRAND = "Element"; + +/** + * Returns whether the app is currently branded. + * This is currently a naive check of whether the `brand` config starts with the substring `Element ` or is the literal `Element`, + * which correctly covers `Element` (release), `Element Nightly` & `Element Pro`. + */ +export const isElementBranded = (): boolean => { + const brand = SdkConfig.get("brand"); + return brand === ELEMENT_BRAND || brand.startsWith(ELEMENT_BRAND + " "); +}; diff --git a/apps/web/src/components/structures/MatrixChat.tsx b/apps/web/src/components/structures/MatrixChat.tsx index e93799fb1a..14e726e532 100644 --- a/apps/web/src/components/structures/MatrixChat.tsx +++ b/apps/web/src/components/structures/MatrixChat.tsx @@ -350,7 +350,11 @@ export default class MatrixChat extends React.PureComponent { ); // remove the loginToken or auth code from the URL regardless - if (!!this.props.urlParams.legacy_sso || !!this.props.urlParams.oidc) { + if ( + !!this.props.urlParams.legacy_sso || + !!this.props.urlParams.oidc_fragment || + !!this.props.urlParams.oidc_query + ) { this.props.onTokenLoginCompleted(this.props.urlParams, this.getFragmentAfterLogin()); } @@ -408,7 +412,7 @@ export default class MatrixChat extends React.PureComponent { * {@link onWillStartClient} and {@link onClientStarted} will already have been called (but not necessarily * completed). * - * This method either calls {@link onLiggedIn} directly, or switches to {@link Views.E2E_SETUP} or + * This method either calls {@link onLoggedIn} directly, or switches to {@link Views.E2E_SETUP} or * {@link Views.COMPLETE_SECURITY}, which will later call {@link onCompleteSecurityE2eSetupFinished}. */ private async postLoginSetup(): Promise { diff --git a/apps/web/src/components/structures/RoomView.tsx b/apps/web/src/components/structures/RoomView.tsx index 3704cf5885..df8a551c1a 100644 --- a/apps/web/src/components/structures/RoomView.tsx +++ b/apps/web/src/components/structures/RoomView.tsx @@ -1293,23 +1293,30 @@ export class RoomView extends React.Component { } case Action.ComposerInsert: { - if (payload.composerType) break; + const composerInsertPayload = payload as ComposerInsertPayload; + if (composerInsertPayload.composerType) break; - let timelineRenderingType: TimelineRenderingType = payload.timelineRenderingType; + let timelineRenderingType: TimelineRenderingType | undefined; // ThreadView handles Action.ComposerInsert itself due to it having its own editState - if (timelineRenderingType === TimelineRenderingType.Thread) break; + if (composerInsertPayload.timelineRenderingType === TimelineRenderingType.Thread) break; if ( this.state.timelineRenderingType === TimelineRenderingType.Search && - payload.timelineRenderingType === TimelineRenderingType.Search + composerInsertPayload.timelineRenderingType === TimelineRenderingType.Search ) { // we don't have the composer rendered in this state, so bring it back first await this.onCancelSearchClick(); timelineRenderingType = TimelineRenderingType.Room; } + // If the dispatchee didn't request a timeline rendering type, use the current one. + timelineRenderingType = + timelineRenderingType ?? + composerInsertPayload.timelineRenderingType ?? + this.state.timelineRenderingType; + // re-dispatch to the correct composer defaultDispatcher.dispatch({ - ...(payload as ComposerInsertPayload), + ...composerInsertPayload, timelineRenderingType, composerType: this.state.editState ? ComposerType.Edit : ComposerType.Send, }); diff --git a/apps/web/src/components/structures/SpaceHierarchy.tsx b/apps/web/src/components/structures/SpaceHierarchy.tsx index 779ac9f5c2..c1d5976869 100644 --- a/apps/web/src/components/structures/SpaceHierarchy.tsx +++ b/apps/web/src/components/structures/SpaceHierarchy.tsx @@ -184,6 +184,10 @@ const Tile: React.FC = ({ aria-labelledby={checkboxLabelId} checked={!!selected} tabIndex={-1} + onChange={(e) => { + e.stopPropagation(); + onToggleClick(); + }} /> ); } else { @@ -311,9 +315,9 @@ const Tile: React.FC = ({ }; childSection = ( -
+
    {children} -
+ ); } diff --git a/apps/web/src/components/structures/ThreadView.tsx b/apps/web/src/components/structures/ThreadView.tsx index 8ddbbf6367..18179e417f 100644 --- a/apps/web/src/components/structures/ThreadView.tsx +++ b/apps/web/src/components/structures/ThreadView.tsx @@ -167,12 +167,14 @@ export default class ThreadView extends React.Component { } switch (payload.action) { case Action.ComposerInsert: { - if (payload.composerType) break; - if (payload.timelineRenderingType !== TimelineRenderingType.Thread) break; + const insertPayload = payload as ComposerInsertPayload; + if (insertPayload.composerType) break; + if (insertPayload.timelineRenderingType !== TimelineRenderingType.Thread) break; // re-dispatch to the correct composer dis.dispatch({ - ...(payload as ComposerInsertPayload), + ...insertPayload, + timelineRenderingType: TimelineRenderingType.Thread, composerType: this.state.editState ? ComposerType.Edit : ComposerType.Send, }); break; diff --git a/apps/web/src/components/structures/TimelinePanel.tsx b/apps/web/src/components/structures/TimelinePanel.tsx index aeda2237e0..939c16d96e 100644 --- a/apps/web/src/components/structures/TimelinePanel.tsx +++ b/apps/web/src/components/structures/TimelinePanel.tsx @@ -24,7 +24,7 @@ import { type MatrixClient, type Relations, type MatrixError, - type SyncState, + SyncState, TimelineWindow, Thread, ThreadEvent, @@ -192,9 +192,6 @@ interface IState { backPaginating: boolean; forwardPaginating: boolean; - // cache of matrixClient.getSyncState() (but from the 'sync' event) - clientSyncState: SyncState | null; - // should the event tiles have twelve hour times isTwelveHour: boolean; @@ -251,12 +248,17 @@ class TimelinePanel extends React.Component { // A map of private callEventGroupers = new Map(); private initialReadMarkerId: string | null = null; + private syncImpliesForwardPaginating: boolean; public constructor(props: IProps, context: React.ContextType) { super(props, context); debuglog("mounting"); + this.syncImpliesForwardPaginating = TimelinePanel.isSyncForwardPaginating( + MatrixClientPeg.safeGet().getSyncState(), + ); + // XXX: we could track RM per TimelineSet rather than per Room. // but for now we just do it per room for simplicity. if (this.props.manageReadMarkers) { @@ -278,7 +280,6 @@ class TimelinePanel extends React.Component { readMarkerEventId: this.initialReadMarkerId, backPaginating: false, forwardPaginating: false, - clientSyncState: MatrixClientPeg.safeGet().getSyncState(), isTwelveHour: SettingsStore.getValue("showTwelveHourTimestamps"), alwaysShowTimestamps: SettingsStore.getValue("alwaysShowTimestamps"), readMarkerInViewThresholdMs: SettingsStore.getValue("readMarkerInViewThresholdMs"), @@ -899,9 +900,17 @@ class TimelinePanel extends React.Component { private onSync = (clientSyncState: SyncState, prevState: SyncState | null, data?: object): void => { if (this.unmounted) return; - this.setState({ clientSyncState }); + const nextSyncImpliesForwardPaginating = TimelinePanel.isSyncForwardPaginating(clientSyncState); + if (nextSyncImpliesForwardPaginating === this.syncImpliesForwardPaginating) return; + + this.syncImpliesForwardPaginating = nextSyncImpliesForwardPaginating; + this.forceUpdate(); }; + private static isSyncForwardPaginating(syncState: SyncState | null): boolean { + return syncState === SyncState.Prepared || syncState === SyncState.Catchup; + } + private readMarkerTimeout(readMarkerPosition: number | null): number { return readMarkerPosition === 0 ? (this.context?.readMarkerInViewThresholdMs ?? this.state.readMarkerInViewThresholdMs) @@ -1832,8 +1841,7 @@ class TimelinePanel extends React.Component { // If the state is PREPARED or CATCHUP, we're still waiting for the js-sdk to sync with // the HS and fetch the latest events, so we are effectively forward paginating. - const forwardPaginating = - this.state.forwardPaginating || ["PREPARED", "CATCHUP"].includes(this.state.clientSyncState!); + const forwardPaginating = this.state.forwardPaginating || this.syncImpliesForwardPaginating; const events = this.state.events; return ( { <>

{_t("auth|reset_password_title")}

-
+
{ this.setState({ logoutDevices: !this.state.logoutDevices })} checked={this.state.logoutDevices} + formWrap={false} > {_t("auth|reset_password|sign_out_other_devices")}
{this.state.errorText && } -
- +
); } @@ -433,7 +434,7 @@ export default class ForgotPassword extends React.Component {

{_t("auth|reset_password|reset_successful")}

{this.state.logoutDevices ?

{_t("auth|reset_password|devices_logout_success")}

: null} - diff --git a/apps/web/src/components/structures/auth/Login.tsx b/apps/web/src/components/structures/auth/Login.tsx index 582473a028..dca3b30ee0 100644 --- a/apps/web/src/components/structures/auth/Login.tsx +++ b/apps/web/src/components/structures/auth/Login.tsx @@ -441,7 +441,7 @@ class LoginComponent extends React.PureComponent {
diff --git a/apps/web/src/components/structures/auth/forgot-password/EnterEmail.tsx b/apps/web/src/components/structures/auth/forgot-password/EnterEmail.tsx index 75907b2a5e..9d88f1c8ce 100644 --- a/apps/web/src/components/structures/auth/forgot-password/EnterEmail.tsx +++ b/apps/web/src/components/structures/auth/forgot-password/EnterEmail.tsx @@ -75,7 +75,7 @@ export const EnterEmail: React.FC = ({ />
{errorText && } -
diff --git a/apps/web/src/components/views/auth/AuthPage.tsx b/apps/web/src/components/views/auth/AuthPage.tsx index adc901f6c9..8d1c56ea7c 100644 --- a/apps/web/src/components/views/auth/AuthPage.tsx +++ b/apps/web/src/components/views/auth/AuthPage.tsx @@ -31,16 +31,13 @@ export default class AuthPage extends React.PureComponent { + const brand = SdkConfig.get("brand"); + const branding = SdkConfig.getObject("branding"); + const logoUrl = branding.get("auth_header_logo_url"); + + const showGuestFunctions = !!MatrixClientPeg.get(); + const isElement = isElementBranded(); + + return ( +
+ + {brand} + + + {isElement ? _t("welcome|title_element") : _t("welcome|title_generic", { brand })} + + {isElement && {_t("welcome|tagline_element")}} + +
+ + + {showGuestFunctions && ( + + )} +
+
+ ); +}; + +export default DefaultWelcome; diff --git a/apps/web/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/apps/web/src/components/views/auth/InteractiveAuthEntryComponents.tsx index 49443bdd2e..edd44019ba 100644 --- a/apps/web/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/apps/web/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -459,8 +459,8 @@ export class EmailIdentityAuthEntry extends React.Component< { a: (text: string) => ( - - {text} + + {text} ), @@ -475,6 +475,7 @@ export class EmailIdentityAuthEntry extends React.Component< { a: (text: string) => ( { } } - private async updateMode(mode: Mode): Promise { - this.setState({ phase: Phase.Loading }); + private async updateMode(mode: Mode, showLoading = true): Promise { if (this.state.rendezvous) { const rendezvous = this.state.rendezvous; rendezvous.onFailure = undefined; this.setState({ rendezvous: undefined }); } + if (showLoading) { + this.setState({ phase: Phase.Loading }); + } if (mode === Mode.Show) { await this.generateAndShowCode(); } @@ -187,9 +189,23 @@ export default class LoginWithQR extends React.Component { } }; - private onFailure = (reason: RendezvousFailureReason): void => { + private onFailure = async (reason: RendezvousFailureReason): Promise => { if (this.state.phase === Phase.Error) return; // Already in failed state logger.info(`Rendezvous failed: ${reason}`); + + // Generate a new rendezvous channel & qr code if we hit expiry whilst still showing the QR code + if (reason === ClientRendezvousFailureReason.Expired && this.state.phase === Phase.ShowingQR) { + try { + this.reset(); + // Add a sleep to make the UX looks less flickery and more intentional + await sleep(1000); + await this.updateMode(Mode.Show, false); + return; + } catch (e) { + logger.warn("Failed to re-roll qr code on expiry", e); + } + } + this.setState({ phase: Phase.Error, failureReason: reason }); }; @@ -200,7 +216,6 @@ export default class LoginWithQR extends React.Component { failureReason: undefined, userCode: undefined, checkCode: undefined, - mediaPermissionError: false, }); } diff --git a/apps/web/src/components/views/auth/LoginWithQRFlow.tsx b/apps/web/src/components/views/auth/LoginWithQRFlow.tsx index a432baac72..34a3b22db4 100644 --- a/apps/web/src/components/views/auth/LoginWithQRFlow.tsx +++ b/apps/web/src/components/views/auth/LoginWithQRFlow.tsx @@ -226,39 +226,39 @@ export default class LoginWithQRFlow extends React.Component { ); break; - case Phase.ShowingQR: - if (this.props.code) { - const data = this.props.code; + case Phase.ShowingQR: { + const steps = [ + _t("auth|qr_code_login|open_element_other_device", { + brand: SdkConfig.get().brand, + }), + _t("auth|qr_code_login|select_qr_code", { + scanQRCode: {_t("auth|qr_code_login|scan_qr_code")}, + }), + _t("auth|qr_code_login|point_the_camera"), + _t("auth|qr_code_login|follow_remaining_instructions"), + ]; - main = ( - <> - - {_t("auth|qr_code_login|scan_code_instruction")} - -
- -
-
    -
  1. - {_t("auth|qr_code_login|open_element_other_device", { - brand: SdkConfig.get().brand, - })} -
  2. -
  3. - {_t("auth|qr_code_login|select_qr_code", { - scanQRCode: {_t("auth|qr_code_login|scan_qr_code")}, - })} -
  4. -
  5. {_t("auth|qr_code_login|point_the_camera")}
  6. -
  7. {_t("auth|qr_code_login|follow_remaining_instructions")}
  8. -
- - ); - } else { - main = this.simpleSpinner(); - buttons = this.cancelButton(); - } + main = ( + <> + + {_t("auth|qr_code_login|scan_code_instruction")} + +
+ {this.props.code ? ( + + ) : ( + + )} +
+
    + {steps.map((step, i) => ( +
  1. {step}
  2. + ))} +
+ + ); break; + } case Phase.Loading: main = this.simpleSpinner(); break; diff --git a/apps/web/src/components/views/auth/PasswordLogin.tsx b/apps/web/src/components/views/auth/PasswordLogin.tsx index 940e606780..1fa0c4fa38 100644 --- a/apps/web/src/components/views/auth/PasswordLogin.tsx +++ b/apps/web/src/components/views/auth/PasswordLogin.tsx @@ -434,7 +434,7 @@ export default class PasswordLogin extends React.PureComponent { /> {forgotPasswordJsx} {!this.props.busy && ( - )} diff --git a/apps/web/src/components/views/auth/RegistrationForm.tsx b/apps/web/src/components/views/auth/RegistrationForm.tsx index 113ea833ea..10b01087e8 100644 --- a/apps/web/src/components/views/auth/RegistrationForm.tsx +++ b/apps/web/src/components/views/auth/RegistrationForm.tsx @@ -549,7 +549,7 @@ export default class RegistrationForm extends React.PureComponent + ); diff --git a/apps/web/src/components/views/auth/Welcome.tsx b/apps/web/src/components/views/auth/Welcome.tsx index e2b64028e4..f8c00b2127 100644 --- a/apps/web/src/components/views/auth/Welcome.tsx +++ b/apps/web/src/components/views/auth/Welcome.tsx @@ -5,9 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React from "react"; +import React, { type ReactNode } from "react"; import classNames from "classnames"; import { type EmptyObject } from "matrix-js-sdk/src/matrix"; +import { Glass } from "@vector-im/compound-web"; import SdkConfig from "../../../SdkConfig"; import AuthPage from "./AuthPage"; @@ -16,14 +17,12 @@ import { UIFeature } from "../../../settings/UIFeature"; import LanguageSelector from "./LanguageSelector"; import EmbeddedPage from "../../structures/EmbeddedPage"; import { MATRIX_LOGO_HTML } from "../../structures/static-page-vars"; +import DefaultWelcome from "./DefaultWelcome.tsx"; export default class Welcome extends React.PureComponent { public render(): React.ReactNode { const pagesConfig = SdkConfig.getObject("embedded_pages"); - let pageUrl: string | undefined; - if (pagesConfig) { - pageUrl = pagesConfig.get("welcome_url"); - } + const pageUrl = pagesConfig?.get("welcome_url"); const replaceMap: Record = { "$brand": SdkConfig.get("brand"), @@ -33,25 +32,25 @@ export default class Welcome extends React.PureComponent { "[matrix]": MATRIX_LOGO_HTML, }; - if (!pageUrl) { - // Fall back to default and replace $logoUrl in welcome.html - const brandingConfig = SdkConfig.getObject("branding"); - const logoUrl = brandingConfig?.get("auth_header_logo_url") ?? "themes/element/img/logos/element-logo.svg"; - replaceMap["$logoUrl"] = logoUrl; - pageUrl = "welcome.html"; + let body: ReactNode; + if (pageUrl) { + body = ; + } else { + body = ; } return ( - -
- - -
+ + +
+ {body} + +
+
); } diff --git a/apps/web/src/components/views/dialogs/CreateSectionDialog.tsx b/apps/web/src/components/views/dialogs/CreateSectionDialog.tsx new file mode 100644 index 0000000000..d8aaed3326 --- /dev/null +++ b/apps/web/src/components/views/dialogs/CreateSectionDialog.tsx @@ -0,0 +1,77 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { useState, type JSX } from "react"; +import { Flex } from "@element-hq/web-shared-components"; +import { Form, Text } from "@vector-im/compound-web"; + +import BaseDialog from "./BaseDialog"; +import DialogButtons from "../elements/DialogButtons"; +import { _t } from "../../../languageHandler"; + +interface CreateSectionDialogProps { + /** + * The name of the section being edited if defined. Otherwise, create a new section. + */ + sectionToEdit?: string; + + /** + * Callback called when the dialog is closed. + * @param shouldCreateSection Whether a section should be created or not. This will be false if the user cancels the dialog. + * @param sectionName The name of the section to create. + */ + onFinished: (shouldCreateSection: boolean, sectionName: string) => void; +} + +/** + * Dialog shown to the user to create a new section in the room list. + */ +export function CreateSectionDialog({ onFinished, sectionToEdit }: CreateSectionDialogProps): JSX.Element { + const isEdition = Boolean(sectionToEdit); + const [value, setValue] = useState(sectionToEdit ?? ""); + const isInvalid = Boolean(value.trim().length === 0); + + return ( + onFinished(false, value)} + title={isEdition ? _t("create_section_dialog|title_edition") : _t("create_section_dialog|title")} + hasCancel={true} + > + + + {_t("create_section_dialog|description")} + + { + e.preventDefault(); + if (!isInvalid) onFinished(true, value); + }} + > + + {_t("create_section_dialog|label")} + setValue(evt.target.value)} + required={true} + /> + + + + onFinished(false, "")} + onPrimaryButtonClick={() => onFinished(true, value)} + /> + + ); +} diff --git a/apps/web/src/components/views/dialogs/ForwardDialog.tsx b/apps/web/src/components/views/dialogs/ForwardDialog.tsx index a6a6954bc5..6417fd985c 100644 --- a/apps/web/src/components/views/dialogs/ForwardDialog.tsx +++ b/apps/web/src/components/views/dialogs/ForwardDialog.tsx @@ -50,9 +50,9 @@ import { RoomContextDetails } from "../rooms/RoomContextDetails"; import { filterBoolean } from "../../../utils/arrays"; import { type IState, + RovingStateActionType, RovingTabIndexContext, RovingTabIndexProvider, - Type, useRovingTabIndex, } from "../../../accessibility/RovingTabIndex"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; @@ -368,7 +368,7 @@ const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCr const node = context.state.nodes[0]; if (node) { context.dispatch({ - type: Type.SetFocus, + type: RovingStateActionType.SetFocus, payload: { node }, }); node?.scrollIntoView?.({ diff --git a/apps/web/src/components/views/dialogs/InviteDialog.tsx b/apps/web/src/components/views/dialogs/InviteDialog.tsx index 8a9c36e5de..a927129241 100644 --- a/apps/web/src/components/views/dialogs/InviteDialog.tsx +++ b/apps/web/src/components/views/dialogs/InviteDialog.tsx @@ -12,10 +12,9 @@ import { KnownMembership } from "matrix-js-sdk/src/types"; import { type MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { logger } from "matrix-js-sdk/src/logger"; import { uniqBy } from "lodash"; -import { RichList, RichItem, PillInput, Pill } from "@element-hq/web-shared-components"; +import { Pill, PillInput, RichList } from "@element-hq/web-shared-components"; import { DialPadIcon, UserProfileSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -import { Icon as EmailPillAvatarIcon } from "../../../../res/img/icon-email-pill-avatar.svg"; import { _t, _td } from "../../../languageHandler"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { makeRoomPermalink, makeUserPermalink } from "../../../utils/permalinks/Permalinks"; @@ -31,8 +30,6 @@ import { DefaultTagID } from "../../../stores/room-list-v3/skip-list/tag"; import RoomListStore from "../../../stores/room-list/RoomListStore"; import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; -import { mediaFromMxc } from "../../../customisations/Media"; -import BaseAvatar from "../avatars/BaseAvatar"; import { SearchResultAvatar } from "../avatars/SearchResultAvatar"; import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton"; import { selectText } from "../../../utils/strings"; @@ -43,7 +40,6 @@ import QuestionDialog from "./QuestionDialog"; import BaseDialog from "./BaseDialog"; import DialPadBackspaceButton from "../elements/DialPadBackspaceButton"; import LegacyCallHandler from "../../../LegacyCallHandler"; -import UserIdentifierCustomisations from "../../../customisations/UserIdentifier"; import CopyableText from "../elements/CopyableText"; import { type ScreenName } from "../../../PosthogTrackers"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; @@ -64,9 +60,10 @@ import { SdkContextClass } from "../../../contexts/SDKContext"; import { type UserProfilesStore } from "../../../stores/UserProfilesStore"; import InviteProgressBody from "./InviteProgressBody.tsx"; import MultiInviter, { type CompletionStates as MultiInviterCompletionStates } from "../../../utils/MultiInviter.ts"; - -// we have a number of types defined from the Matrix spec which can't reasonably be altered here. -/* eslint-disable camelcase */ +import { DMRoomTile } from "./invite/DMRoomTile.tsx"; +import { logErrorAndShowErrorDialog } from "../../../utils/ErrorUtils.tsx"; +import UnknownIdentityUsersWarningDialog from "./invite/UnknownIdentityUsersWarningDialog.tsx"; +import { AddressType, getAddressType } from "../../../UserAddress.ts"; interface Result { userId: string; @@ -117,62 +114,6 @@ const toMember = (member: RoomMember | Member): Member => { : member; }; -interface IDMRoomTileProps { - member: Member; - lastActiveTs?: number; - onToggle(member: Member): void; - isSelected: boolean; -} - -class DMRoomTile extends React.PureComponent { - private onClick = (e: ButtonEvent): void => { - // Stop the browser from highlighting text - e.preventDefault(); - e.stopPropagation(); - - this.props.onToggle(this.props.member); - }; - - public render(): React.ReactNode { - const avatarSize = "32px"; - const avatar = (this.props.member as ThreepidMember).isEmail ? ( - - ) : ( - - ); - - const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier(this.props.member.userId, { - withDisplayName: true, - }); - - const caption = (this.props.member as ThreepidMember).isEmail - ? _t("invite|email_caption") - : userIdentifier || this.props.member.userId; - - return ( - - ); - } -} - interface BaseProps { // Takes a boolean which is true if a user / users were invited / // a call transfer was initiated or false if the dialog was cancelled @@ -223,6 +164,14 @@ interface IInviteDialogState { dialPadValue: string; currentTabId: TabId; + /** + * If we tried to invite some users whose identity we don't know, we will show a warning. + * This is the list of users. (If it is `null`, we are not showing that warning.) + * + * Will never be the empty list. + */ + unknownIdentityUsers: Member[] | null; + /** * True if we are sending the invites. * @@ -292,7 +241,8 @@ export default class InviteDialog extends React.PureComponent { + if (this.props.kind === InviteKind.Dm) { + await this.startDm(); + } else if (this.props.kind === InviteKind.Invite) { + await this.inviteUsers(); + } else { + throw new Error("Unknown InviteKind: " + this.props.kind); + } + } + private transferCall = async (): Promise => { if (this.props.kind !== InviteKind.CallTransfer) return; if (this.state.currentTabId == TabId.UserDirectory) { @@ -1186,14 +1151,48 @@ export default class InviteDialog extends React.PureComponent { + this.setBusy(true); + + const targets = this.convertFilter(); + const unknownIdentityUsers: Member[] = []; + const cli = MatrixClientPeg.safeGet(); + const crypto = cli.getCrypto(); + if (crypto) { + for (const t of targets) { + const addressType = getAddressType(t.userId); + if ( + addressType !== AddressType.MatrixUserId || + !(await crypto.getUserVerificationStatus(t.userId)).known + ) { + unknownIdentityUsers.push(t); + } + } + } + + // If we have some users with unknown identities, show the warning page. + if (unknownIdentityUsers.length > 0) { + logger.debug( + "InviteDialog: Warning about users with unknown identities:", + unknownIdentityUsers.map((u) => u.userId), + ); + this.setState({ unknownIdentityUsers: unknownIdentityUsers, busy: false }); + } else { + // Otherwise, transition directly to sending the relevant invites. + await this.startDmOrSendInvites(); + } + } + + /** + * Render content of the "users" that is used for both invites and "start chat". */ private renderMainTab(): JSX.Element { let helpText; let buttonText; - let goButtonFn: (() => Promise) | null = null; - const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer); const cli = MatrixClientPeg.safeGet(); @@ -1230,7 +1229,6 @@ export default class InviteDialog extends React.PureComponent - {buttonText} -
- ); + const onGoButtonPressed = (): void => { + this.onGoButtonPressed().catch((e) => logErrorAndShowErrorDialog("Error processing invites", e)); + }; return (

{helpText}

{this.renderEditor()} - {goButton} + + {buttonText} +
{this.state.busy ? : this.renderSuggestions()}
); } + /** Callback function, which handles the user clicking "Remove" on the {@link UnknwownIdentityUsersWarningDialog}. */ + private onRemoveUnknownIdentityUsersClicked = (): void => { + // Remove the unknown identity users, then return to the previous screen + const newTargets: Member[] = []; + for (const target of this.state.targets) { + if (!this.state.unknownIdentityUsers?.find((m) => m.userId == target.userId)) { + newTargets.push(target); + } + } + this.setState({ + targets: newTargets, + unknownIdentityUsers: null, + }); + }; + /** * Render the complete dialog, given this is not a call transfer dialog. * * See also: {@link renderCallTransferDialog}. */ private renderRegularDialog(): React.ReactNode { + if (this.props.kind !== InviteKind.Dm && this.props.kind !== InviteKind.Invite) { + throw new Error("Unsupported InviteDialog kind: " + this.props.kind); + } + + if (this.state.unknownIdentityUsers !== null) { + return ( + { + this.setState({ unknownIdentityUsers: null }); + this.startDmOrSendInvites().catch((e) => + logErrorAndShowErrorDialog("Error processing invites", e), + ); + }} + onRemove={this.onRemoveUnknownIdentityUsersClicked} + screenName={this.screenName} + kind={this.props.kind} + users={this.state.unknownIdentityUsers} + /> + ); + } + let title; if (this.props.kind === InviteKind.Dm) { title = _t("space|add_existing_room_space|dm_heading"); @@ -1342,7 +1377,12 @@ export default class InviteDialog extends React.PureComponent +
{this.renderEditor()}
+ {this.state.busy ? : this.renderSuggestions()} + + ); const tabs: NonEmptyArray> = [ new Tab( diff --git a/apps/web/src/components/views/dialogs/RemoveSectionDialog.tsx b/apps/web/src/components/views/dialogs/RemoveSectionDialog.tsx new file mode 100644 index 0000000000..fe2a715a8b --- /dev/null +++ b/apps/web/src/components/views/dialogs/RemoveSectionDialog.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; +import { type JSX } from "react"; +import { Text } from "@vector-im/compound-web"; + +import { _t } from "../../../languageHandler"; +import BaseDialog from "./BaseDialog"; +import DialogButtons from "../elements/DialogButtons"; + +interface RemoveSectionDialogProps { + onFinished: (shouldRemoveSection: boolean) => void; + /** Whether the section is empty */ + isEmpty: boolean; +} + +/** + * Dialog shown to the user to remove section in the room list. + */ +export function RemoveSectionDialog({ onFinished, isEmpty }: RemoveSectionDialogProps): JSX.Element { + return ( + onFinished(false)} + title={_t("remove_section_dialog|title")} + hasCancel={true} + > + {_t("remove_section_dialog|confirmation")} + {!isEmpty && ( + <> +
+ {_t("remove_section_dialog|description")} + + )} + onFinished(false)} + onPrimaryButtonClick={() => onFinished(true)} + /> +
+ ); +} diff --git a/apps/web/src/components/views/dialogs/RoomSettingsDialog.tsx b/apps/web/src/components/views/dialogs/RoomSettingsDialog.tsx index 10e4a3e428..4488c6bf93 100644 --- a/apps/web/src/components/views/dialogs/RoomSettingsDialog.tsx +++ b/apps/web/src/components/views/dialogs/RoomSettingsDialog.tsx @@ -42,6 +42,7 @@ import { type NonEmptyArray } from "../../../@types/common"; import { PollHistoryTab } from "../settings/tabs/room/PollHistoryTab"; import ErrorBoundary from "../elements/ErrorBoundary"; import { PeopleRoomSettingsTab } from "../settings/tabs/room/PeopleRoomSettingsTab"; +import { SDKContext, type SdkContextClass } from "../../../contexts/SDKContext"; export const enum RoomSettingsTab { General = "ROOM_GENERAL_TAB", @@ -59,6 +60,7 @@ interface IProps { roomId: string; onFinished: (success?: boolean) => void; initialTabId?: RoomSettingsTab; + sdkContext: SdkContextClass; } interface IState { @@ -238,21 +240,23 @@ class RoomSettingsDialog extends React.Component { public render(): React.ReactNode { const roomName = this.state.room.name; return ( - -
- -
-
+ + +
+ +
+
+
); } } diff --git a/apps/web/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx b/apps/web/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx index 33ca9c510b..6f71f0762c 100644 --- a/apps/web/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx +++ b/apps/web/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx @@ -104,7 +104,12 @@ export default class WidgetCapabilitiesPromptDialog extends React.PureComponent< return (
- this.onToggle(cap)} description={text.byline}> + this.onToggle(cap)} + description={text.byline} + formWrap={false} + > {text.primary}
diff --git a/apps/web/src/components/views/dialogs/devtools/Crypto.tsx b/apps/web/src/components/views/dialogs/devtools/Crypto.tsx index 1c02f062c3..3e4cda666e 100644 --- a/apps/web/src/components/views/dialogs/devtools/Crypto.tsx +++ b/apps/web/src/components/views/dialogs/devtools/Crypto.tsx @@ -110,7 +110,11 @@ function KeyStorage(): JSX.Element { return ( - {_t("devtools|crypto|key_storage")} + + + + + @@ -212,7 +216,11 @@ function CrossSigning(): JSX.Element { return (
{_t("devtools|crypto|key_storage")}
{_t("devtools|crypto|key_backup_latest_version")}
- {_t("devtools|crypto|cross_signing")} + + + + + @@ -303,7 +311,11 @@ function Session(): JSX.Element { return (
{_t("devtools|crypto|cross_signing")}
{_t("devtools|crypto|cross_signing_status")}
- {_t("devtools|crypto|session")} + + + + + diff --git a/apps/web/src/components/views/dialogs/devtools/StickyEventState.tsx b/apps/web/src/components/views/dialogs/devtools/StickyEventState.tsx index 159fbc113b..96bebd5953 100644 --- a/apps/web/src/components/views/dialogs/devtools/StickyEventState.tsx +++ b/apps/web/src/components/views/dialogs/devtools/StickyEventState.tsx @@ -9,7 +9,6 @@ import React, { type ChangeEvent, useContext, useEffect, useMemo, useState } fro import { Pill } from "@element-hq/web-shared-components"; import { MatrixEvent, type IContent, RoomStickyEventsEvent } from "matrix-js-sdk/src/matrix"; import { Alert, Form, SettingsToggleInput } from "@vector-im/compound-web"; -import { v4 as uuidv4 } from "uuid"; import BaseTool, { DevtoolsContext, type IDevtoolsProps } from "./BaseTool.tsx"; import { _t, _td, UserFriendlyError } from "../../../../languageHandler.tsx"; @@ -330,7 +329,7 @@ export const StickyEventEditor: React.FC = ({ mxEvent, onBack }) = const defaultContent = mxEvent ? stringify(mxEvent.getContent()) : stringify({ - msc4354_sticky_key: uuidv4(), + msc4354_sticky_key: window.crypto.randomUUID(), }); return ; }; diff --git a/apps/web/src/components/views/dialogs/invite/DMRoomTile.tsx b/apps/web/src/components/views/dialogs/invite/DMRoomTile.tsx new file mode 100644 index 0000000000..8998977cf2 --- /dev/null +++ b/apps/web/src/components/views/dialogs/invite/DMRoomTile.tsx @@ -0,0 +1,74 @@ +/* + Copyright 2026 Element Creations Ltd. + + SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; +import { RichItem } from "@element-hq/web-shared-components"; + +import { type Member, type ThreepidMember } from "../../../../utils/direct-messages.ts"; +import type { ButtonEvent } from "../../elements/AccessibleButton.tsx"; +import BaseAvatar from "../../avatars/BaseAvatar.tsx"; +import { mediaFromMxc } from "../../../../customisations/Media.ts"; +import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier.ts"; +import { _t } from "../../../../languageHandler.tsx"; +import { Icon as EmailPillAvatarIcon } from "../../../../../res/img/icon-email-pill-avatar.svg"; + +interface IDMRoomTileProps { + member: Member; + lastActiveTs?: number; + onToggle?(member: Member): void; + isSelected?: boolean; +} + +/** A tile representing a single user in the "suggestions"/"recents" section of the invite dialog. */ +export class DMRoomTile extends React.PureComponent { + private onClick = (e: ButtonEvent): void => { + // Stop the browser from highlighting text + e.preventDefault(); + e.stopPropagation(); + + this.props.onToggle?.(this.props.member); + }; + + public render(): React.ReactNode { + const avatarSize = "32px"; + const avatar = (this.props.member as ThreepidMember).isEmail ? ( + + ) : ( + + ); + + const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier(this.props.member.userId, { + withDisplayName: true, + }); + + const caption = (this.props.member as ThreepidMember).isEmail + ? _t("invite|email_caption") + : userIdentifier || this.props.member.userId; + + return ( + + ); + } +} diff --git a/apps/web/src/components/views/dialogs/invite/UnknownIdentityUsersWarningDialog.tsx b/apps/web/src/components/views/dialogs/invite/UnknownIdentityUsersWarningDialog.tsx new file mode 100644 index 0000000000..a2c4b4a01a --- /dev/null +++ b/apps/web/src/components/views/dialogs/invite/UnknownIdentityUsersWarningDialog.tsx @@ -0,0 +1,121 @@ +/* + Copyright 2026 Element Creations Ltd. + + SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX, useCallback } from "react"; +import { CheckIcon, CloseIcon, UserAddSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { Button, PageHeader } from "@vector-im/compound-web"; + +import { InviteKind } from "../InviteDialogTypes.ts"; +import { type Member } from "../../../../utils/direct-messages.ts"; +import BaseDialog from "../BaseDialog.tsx"; +import { type ScreenName } from "../../../../PosthogTrackers.ts"; +import { DMRoomTile } from "./DMRoomTile.tsx"; +import { _t } from "../../../../languageHandler.tsx"; + +interface Props { + /** Callback that will be called when the 'Continue' or 'Invite' button is clicked. */ + onContinue: () => void; + + /** Callback that will be called when the 'Cancel' button is clicked. Unused unless {@link kind} is {@link InviteKind.Dm}. */ + onCancel: () => void; + + /** Callback that will be called when the 'Remove' button is clicked. Unused unless {@link kind} is {@link InviteKind.Invite}. */ + onRemove: () => void; + + /** Optional Posthog ScreenName to supply during the lifetime of this dialog. */ + screenName: ScreenName | undefined; + + /** The type of invite dialog: whether we are starting a new DM, or inviting users to an existing room */ + kind: InviteKind.Dm | InviteKind.Invite; + + /** The users whose identities we don't know */ + users: Member[]; +} + +/** + * Part of the invite dialog: a screen that appears if there are any users whose cryptographic identity we don't know, + * to confirm that they are the right users. + * + * Figma: https://www.figma.com/design/chAcaQAluTuRg6BsG4Npc0/-3163--Inviting-Unknown-People?node-id=150-17719&t=ISAikbnj97LM4NwT-0 + */ +const UnknownIdentityUsersWarningDialog: React.FC = (props) => { + const userListItem = useCallback((u: Member) => , []); + + let title: string; + let headerText: string; + let buttons: JSX.Element; + + switch (props.kind) { + case InviteKind.Invite: + title = _t("invite|confirm_unknown_users|invite_title"); + headerText = _t("invite|confirm_unknown_users|invite_subtitle"); + buttons = ; + break; + + case InviteKind.Dm: + title = + props.users.length == 1 + ? _t("invite|confirm_unknown_users|start_chat_title_one_user") + : _t("invite|confirm_unknown_users|start_chat_title_multiple_users"); + + headerText = + props.users.length == 1 + ? _t("invite|confirm_unknown_users|start_chat_subtitle_one_user") + : _t("invite|confirm_unknown_users|start_chat_subtitle_multiple_users"); + + buttons = ; + break; + } + + return ( + +
+ +

{headerText}

+
+
+ +
    + {props.users.map(userListItem)} +
+ +
{buttons}
+
+ ); +}; + +const DmButtons: React.FC<{ onContinue: () => void; onCancel: () => void }> = (props) => { + return ( + <> + + + + ); +}; + +const InviteButtons: React.FC<{ onInvite: () => void; onRemove: () => void }> = (props) => { + return ( + <> + + + + ); +}; + +export default UnknownIdentityUsersWarningDialog; diff --git a/apps/web/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/apps/web/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index de68c42ee4..d8874a4d95 100644 --- a/apps/web/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/apps/web/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -44,10 +44,10 @@ import { import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts"; import { - findSiblingElement, + findNextSiblingElement, + RovingStateActionType, RovingTabIndexContext, RovingTabIndexProvider, - Type, } from "../../../../accessibility/RovingTabIndex"; import { mediaFromMxc } from "../../../../customisations/Media"; import { Action } from "../../../../dispatcher/actions"; @@ -537,7 +537,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n const node = rovingContext.state.nodes[0]; if (node) { rovingContext.dispatch({ - type: Type.SetFocus, + type: RovingStateActionType.SetFocus, payload: { node }, }); node?.scrollIntoView?.({ @@ -1181,7 +1181,10 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n } const idx = nodes.indexOf(rovingContext.state.activeNode); - node = findSiblingElement(nodes, idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1)); + node = findNextSiblingElement( + nodes, + idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1), + ); } break; @@ -1201,7 +1204,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n const nodes = rovingContext.state.nodes.filter(nodeIsForRecentlyViewed); const idx = nodes.indexOf(rovingContext.state.activeNode); - node = findSiblingElement( + node = findNextSiblingElement( nodes, idx + (accessibilityAction === KeyBindingAction.ArrowLeft ? -1 : 1), ); @@ -1211,7 +1214,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n if (node) { rovingContext.dispatch({ - type: Type.SetFocus, + type: RovingStateActionType.SetFocus, payload: { node }, }); node?.scrollIntoView({ diff --git a/apps/web/src/components/views/elements/AccessibleButton.tsx b/apps/web/src/components/views/elements/AccessibleButton.tsx index a2018b2211..3032312b32 100644 --- a/apps/web/src/components/views/elements/AccessibleButton.tsx +++ b/apps/web/src/components/views/elements/AccessibleButton.tsx @@ -152,46 +152,49 @@ const AccessibleButton = function AccessibleButton) => { - const action = getKeyBindingsManager().getAccessibilityAction(e); - switch (action) { - case KeyBindingAction.Enter: - e.stopPropagation(); - e.preventDefault(); - return onClick?.(e); - case KeyBindingAction.Space: - e.stopPropagation(); - e.preventDefault(); - break; - default: - onKeyDown?.(e); - } - }; - newProps.onKeyUp = (e: KeyboardEvent) => { - const action = getKeyBindingsManager().getAccessibilityAction(e); + if (element !== "button") { + // 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 + // It's because we are using html buttons at a few places e.g. inside dialogs + // And divs which we report as role button to assistive technologies. + // Browsers handle space and enter key presses differently and we are only adjusting to the + // inconsistencies here + newProps.onKeyDown = (e: KeyboardEvent) => { + const action = getKeyBindingsManager().getAccessibilityAction(e); - switch (action) { - case KeyBindingAction.Enter: - e.stopPropagation(); - e.preventDefault(); - break; - case KeyBindingAction.Space: - e.stopPropagation(); - e.preventDefault(); - return onClick?.(e); - default: - onKeyUp?.(e); - break; - } - }; + switch (action) { + case KeyBindingAction.Enter: + e.stopPropagation(); + e.preventDefault(); + return onClick?.(e); + case KeyBindingAction.Space: + e.stopPropagation(); + e.preventDefault(); + break; + default: + onKeyDown?.(e); + } + }; + newProps.onKeyUp = (e: KeyboardEvent) => { + const action = getKeyBindingsManager().getAccessibilityAction(e); + + switch (action) { + case KeyBindingAction.Enter: + e.stopPropagation(); + e.preventDefault(); + break; + case KeyBindingAction.Space: + e.stopPropagation(); + e.preventDefault(); + return onClick?.(e); + default: + onKeyUp?.(e); + break; + } + }; + } } // Pass through the ref - used for keyboard shortcut access to some buttons diff --git a/apps/web/src/components/views/elements/BugReportDialogButton.tsx b/apps/web/src/components/views/elements/BugReportDialogButton.tsx index 284501e7b9..6e06b6da9d 100644 --- a/apps/web/src/components/views/elements/BugReportDialogButton.tsx +++ b/apps/web/src/components/views/elements/BugReportDialogButton.tsx @@ -34,7 +34,7 @@ export function BugReportDialogButton({ return null; } return ( - diff --git a/apps/web/src/components/views/elements/Spinner.tsx b/apps/web/src/components/views/elements/Spinner.tsx index a3ba625e89..4df3177976 100644 --- a/apps/web/src/components/views/elements/Spinner.tsx +++ b/apps/web/src/components/views/elements/Spinner.tsx @@ -15,6 +15,11 @@ interface IProps { size?: number; message?: string; onFinished: any; // XXX: Spinner pretends to be a dialog so it must accept an onFinished, but it never calls it + /** + * Whether to render the content in a div or span. + * @default "div" + */ + as?: "span" | "div"; } export default class Spinner extends React.PureComponent { @@ -23,16 +28,16 @@ export default class Spinner extends React.PureComponent { }; public render(): React.ReactNode { - const { size, message } = this.props; + const { size, message, as: Component = "div" } = this.props; return ( -
+ {message && (
{message}
 
)} -
+ ); } } diff --git a/apps/web/src/components/views/elements/StyledCheckbox.tsx b/apps/web/src/components/views/elements/StyledCheckbox.tsx index e4cde65d16..97f2166cc1 100644 --- a/apps/web/src/components/views/elements/StyledCheckbox.tsx +++ b/apps/web/src/components/views/elements/StyledCheckbox.tsx @@ -14,6 +14,7 @@ interface IProps extends React.InputHTMLAttributes { inputRef?: Ref; id?: string; description?: ReactNode; + formWrap?: boolean; } const StyledCheckbox: React.FC = ({ @@ -22,30 +23,36 @@ const StyledCheckbox: React.FC = ({ className, inputRef, description, + formWrap = true, ...otherProps }) => { const id = initialId || "checkbox_" + secureRandomString(10); const name = useId(); const descriptionId = useId(); - return ( - - - } - > - {label && } - {description && {description}} - - + + const field = ( + + } + > + {label && } + {description && {description}} + ); + + if (formWrap) { + return {field}; + } + + return field; }; export default StyledCheckbox; diff --git a/apps/web/src/components/views/emojipicker/EmojiPicker.tsx b/apps/web/src/components/views/emojipicker/EmojiPicker.tsx index 192a40b782..c3ad43fd77 100644 --- a/apps/web/src/components/views/emojipicker/EmojiPicker.tsx +++ b/apps/web/src/components/views/emojipicker/EmojiPicker.tsx @@ -25,7 +25,7 @@ import { type IAction as RovingAction, type IState as RovingState, RovingTabIndexProvider, - Type, + RovingStateActionType, } from "../../../accessibility/RovingTabIndex"; import { Key } from "../../../Keyboard"; import { type ButtonEvent } from "../elements/AccessibleButton"; @@ -187,7 +187,7 @@ class EmojiPicker extends React.Component { focusNode?.focus(); } dispatch({ - type: Type.SetFocus, + type: RovingStateActionType.SetFocus, payload: { node: focusNode }, }); @@ -212,7 +212,7 @@ class EmojiPicker extends React.Component { // Reset to first emoji when showing highlight for the first time (or after it was hidden) if (state.nodes.length > 0) { dispatch({ - type: Type.SetFocus, + type: RovingStateActionType.SetFocus, payload: { node: state.nodes[0] }, }); } diff --git a/apps/web/src/components/views/messages/CodeBlock.tsx b/apps/web/src/components/views/messages/CodeBlock.tsx index 026cbba886..04916a9140 100644 --- a/apps/web/src/components/views/messages/CodeBlock.tsx +++ b/apps/web/src/components/views/messages/CodeBlock.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX, useState } from "react"; import classNames from "classnames"; -import { type DOMNode, Element as ParserElement, domToReact } from "html-react-parser"; +import { type DOMNode, type Element as ParserElement, domToReact } from "html-react-parser"; import { textContent, getInnerHTML } from "domutils"; import { CollapseIcon, CopyIcon, ExpandIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; @@ -113,7 +113,7 @@ const CodeBlock: React.FC = ({ preNode }) => { let content = domToReact(preNode.children as DOMNode[]); // Add code element if it's missing since we depend on it - if (!preNode.children.some((child) => child instanceof ParserElement && child.tagName.toUpperCase() === "CODE")) { + if (!preNode.children.some((child) => child.type === "tag" && child.tagName.toUpperCase() === "CODE")) { content = {content}; } diff --git a/apps/web/src/components/views/messages/EditHistoryMessage.tsx b/apps/web/src/components/views/messages/EditHistoryMessage.tsx index 9d533a792a..ced10ce290 100644 --- a/apps/web/src/components/views/messages/EditHistoryMessage.tsx +++ b/apps/web/src/components/views/messages/EditHistoryMessage.tsx @@ -164,7 +164,7 @@ export default class EditHistoryMessage extends React.PureComponent *  - {name} + {name}  {contentElements} ); diff --git a/apps/web/src/components/views/messages/MessageEvent.tsx b/apps/web/src/components/views/messages/MessageEvent.tsx index 549f38226c..d8de22a427 100644 --- a/apps/web/src/components/views/messages/MessageEvent.tsx +++ b/apps/web/src/components/views/messages/MessageEvent.tsx @@ -25,7 +25,6 @@ import UnknownBody from "./UnknownBody"; import { type IMediaBody } from "./IMediaBody"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { type IBodyProps } from "./IBodyProps"; -import TextualBody from "./TextualBody"; import MImageBody from "./MImageBody"; import MVoiceOrAudioBody from "./MVoiceOrAudioBody"; import MStickerBody from "./MStickerBody"; @@ -41,6 +40,7 @@ import { VideoBodyFactory, renderMBody, } from "./MBodyFactory"; +import { TextualBodyFactory } from "./TextualBodyFactory"; // onMessageAllowed is handled internally interface IProps extends Omit { @@ -64,9 +64,9 @@ export interface IOperableEventTile { } const baseBodyTypes = new Map>([ - [MsgType.Text, TextualBody], - [MsgType.Notice, TextualBody], - [MsgType.Emote, TextualBody], + [MsgType.Text, TextualBodyFactory], + [MsgType.Notice, TextualBodyFactory], + [MsgType.Emote, TextualBodyFactory], [MsgType.Image, MImageBody], [MsgType.File, (props: IBodyProps) => renderMBody(props, FileBodyFactory)!], [MsgType.Audio, MVoiceOrAudioBody], @@ -329,6 +329,6 @@ const CaptionBody: React.FunctionComponent (
- +
); diff --git a/apps/web/src/components/views/messages/TextualBody.tsx b/apps/web/src/components/views/messages/TextualBody.tsx deleted file mode 100644 index caf5df344d..0000000000 --- a/apps/web/src/components/views/messages/TextualBody.tsx +++ /dev/null @@ -1,445 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2015-2021 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import React, { type JSX, createRef, type SyntheticEvent, type MouseEvent, useCallback, useEffect } from "react"; -import { MsgType } from "matrix-js-sdk/src/matrix"; -import { - UrlPreviewGroupView, - type UrlPreview, - useCreateAutoDisposedViewModel, - EventContentBodyView, - LINKIFIED_DATA_ATTRIBUTE, - useViewModel, -} from "@element-hq/web-shared-components"; -import { logger as rootLogger } from "matrix-js-sdk/src/logger"; - -import { EventContentBodyViewModel } from "../../../viewmodels/message-body/EventContentBodyViewModel"; -import { formatDate } from "../../../DateUtils"; -import Modal from "../../../Modal"; -import dis from "../../../dispatcher/dispatcher"; -import { _t } from "../../../languageHandler"; -import SettingsStore from "../../../settings/SettingsStore"; -import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; -import { tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks"; -import { Action } from "../../../dispatcher/actions"; -import QuestionDialog from "../dialogs/QuestionDialog"; -import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog"; -import EditMessageComposer from "../rooms/EditMessageComposer"; -import { type IBodyProps } from "./IBodyProps"; -import RoomContext from "../../../contexts/RoomContext"; -import AccessibleButton from "../elements/AccessibleButton"; -import { getParentEventId } from "../../../utils/Reply"; -import { EditWysiwygComposer } from "../rooms/wysiwyg_composer"; -import { type IEventTileOps } from "../rooms/EventTile"; -import { UrlPreviewGroupViewModel } from "../../../viewmodels/message-body/UrlPreviewGroupViewModel.ts"; -import { useMediaVisible } from "../../../hooks/useMediaVisible.ts"; -import ImageView from "../elements/ImageView.tsx"; -import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx"; -import PosthogTrackers from "../../../PosthogTrackers.ts"; - -const logger = rootLogger.getChild("TextualBody"); - -type Props = IBodyProps & { urlPreviewViewModel: UrlPreviewGroupViewModel }; - -class InnerTextualBody extends React.Component { - private readonly contentRef = createRef(); - - public static contextType = RoomContext; - declare public context: React.ContextType; - - private EventContentBodyViewModel: EventContentBodyViewModel; - - public constructor(props: Props, context: React.ContextType) { - super(props, context); - const mxEvent = props.mxEvent; - const content = mxEvent.getContent(); - const isEmote = content.msgtype === MsgType.Emote; - const willHaveWrapper = - !!props.replacingEventId || !!props.isSeeingThroughMessageHiddenForModeration || isEmote; - // only strip reply if this is the original replying event, edits thereafter do not have the fallback - const stripReply = !mxEvent.replacingEvent() && !!getParentEventId(mxEvent); - - this.EventContentBodyViewModel = new EventContentBodyViewModel({ - as: willHaveWrapper ? "span" : "div", - includeDir: false, - mxEvent, - content, - stripReply, - linkify: true, - highlights: props.highlights, - renderTooltipsForAmbiguousLinks: true, - renderKeywordPills: true, - renderMentionPills: true, - renderCodeBlocks: true, - renderSpoilers: true, - client: context.room?.client ?? null, - }); - } - - public updateURLPreviewViewModel(): void { - const content = this.contentRef.current; - if (!content) { - return; - } - (async () => { - try { - void this.props.urlPreviewViewModel.updateEventElement(content); - } catch (ex) { - logger.warn("UrlPreviewViewModel failed to updateEventElement", ex); - } - })(); - } - - public componentDidUpdate(prevProps: Readonly): void { - // Update the ViewModel when relevant props change - const mxEventChanged = prevProps.mxEvent !== this.props.mxEvent; - const highlightsChanged = prevProps.highlights !== this.props.highlights; - const wrapperChanged = - prevProps.replacingEventId !== this.props.replacingEventId || - prevProps.isSeeingThroughMessageHiddenForModeration !== - this.props.isSeeingThroughMessageHiddenForModeration; - - if (mxEventChanged || highlightsChanged || wrapperChanged) { - const mxEvent = this.props.mxEvent; - const content = mxEvent.getContent(); - const isEmote = content.msgtype === MsgType.Emote; - const willHaveWrapper = - !!this.props.replacingEventId || !!this.props.isSeeingThroughMessageHiddenForModeration || isEmote; - // only strip reply if this is the original replying event, edits thereafter do not have the fallback - const stripReply = !mxEvent.replacingEvent() && !!getParentEventId(mxEvent); - - this.EventContentBodyViewModel.setEventContent(mxEvent, content); - this.EventContentBodyViewModel.setStripReply(stripReply); - - if (mxEventChanged || wrapperChanged) { - this.EventContentBodyViewModel.setAs(willHaveWrapper ? "span" : "div"); - } - - if (highlightsChanged) { - this.EventContentBodyViewModel.setHighlights(this.props.highlights); - } - } - this.updateURLPreviewViewModel(); - } - - public componentWillUnmount(): void { - this.EventContentBodyViewModel.dispose(); - } - - public shouldComponentUpdate(nextProps: Readonly): boolean { - // exploit that events are immutable :) - return ( - nextProps.mxEvent.getId() !== this.props.mxEvent.getId() || - nextProps.highlights !== this.props.highlights || - nextProps.replacingEventId !== this.props.replacingEventId || - nextProps.highlightLink !== this.props.highlightLink || - nextProps.editState !== this.props.editState || - nextProps.isSeeingThroughMessageHiddenForModeration !== this.props.isSeeingThroughMessageHiddenForModeration - ); - } - - private onEmoteSenderClick = (): void => { - const mxEvent = this.props.mxEvent; - dis.dispatch({ - action: Action.ComposerInsert, - userId: mxEvent.getSender(), - timelineRenderingType: this.context.timelineRenderingType, - }); - }; - - /** - * This acts as a fallback in-app navigation handler for any body links that - * were ignored as part of linkification because they were already links - * to start with (e.g. pills, links in the content). - */ - private onBodyLinkClick = (e: MouseEvent): void => { - let target: HTMLLinkElement | null = e.target as HTMLLinkElement; - // links processed by linkifyjs have their own handler so don't handle those here - if (target.dataset[LINKIFIED_DATA_ATTRIBUTE]) return; - if (target.nodeName !== "A") { - // Jump to parent as the `` may contain children, e.g. an anchor wrapping an inline code section - target = target.closest("a"); - } - if (!target) return; - - const localHref = tryTransformPermalinkToLocalHref(target.href); - if (localHref !== target.href) { - // it could be converted to a localHref -> therefore handle locally - e.preventDefault(); - window.location.hash = localHref; - } - }; - - public getEventTileOps = (): IEventTileOps => ({ - isWidgetHidden: () => { - // This controls whether the Show preview button is visibile. - return this.props.urlPreviewViewModel.isPreviewHiddenByUser; - }, - - unhideWidget: () => { - (async () => { - try { - await this.props.urlPreviewViewModel.onShowClick(); - } catch (ex) { - logger.warn("UrlPreviewViewModel failed to onShowClick", ex); - } - })(); - }, - }); - - private onStarterLinkClick = (starterLink: string, ev: SyntheticEvent): void => { - ev.preventDefault(); - // We need to add on our scalar token to the starter link, but we may not have one! - // In addition, we can't fetch one on click and then go to it immediately as that - // is then treated as a popup! - // We can get around this by fetching one now and showing a "confirmation dialog" (hurr hurr) - // which requires the user to click through and THEN we can open the link in a new tab because - // the window.open command occurs in the same stack frame as the onClick callback. - - const managers = IntegrationManagers.sharedInstance(); - if (!managers.hasManager()) { - managers.openNoManagerDialog(); - return; - } - - // Go fetch a scalar token - const integrationManager = managers.getPrimaryManager(); - const scalarClient = integrationManager?.getScalarClient(); - scalarClient?.connect().then(() => { - const completeUrl = scalarClient.getStarterLink(starterLink); - const integrationsUrl = integrationManager!.uiUrl; - const { finished } = Modal.createDialog(QuestionDialog, { - title: _t("timeline|scalar_starter_link|dialog_title"), - description: ( -
- {_t("timeline|scalar_starter_link|dialog_description", { integrationsUrl: integrationsUrl })} -
- ), - button: _t("action|continue"), - }); - - finished.then(([confirmed]) => { - if (!confirmed) { - return; - } - const width = window.screen.width > 1024 ? 1024 : window.screen.width; - const height = window.screen.height > 800 ? 800 : window.screen.height; - const left = (window.screen.width - width) / 2; - const top = (window.screen.height - height) / 2; - const features = `height=${height}, width=${width}, top=${top}, left=${left},`; - const wnd = window.open(completeUrl, "_blank", features)!; - wnd.opener = null; - }); - }); - }; - - private openHistoryDialog = async (): Promise => { - Modal.createDialog(MessageEditHistoryDialog, { mxEvent: this.props.mxEvent }); - }; - - private renderEditedMarker(): JSX.Element { - const date = this.props.mxEvent.replacingEventDate(); - const dateString = date && formatDate(date); - - return ( - - {`(${_t("common|edited")})`} - - ); - } - - /** - * Render a marker informing the user that, while they can see the message, - * it is hidden for other users. - */ - private renderPendingModerationMarker(): JSX.Element { - let text; - const visibility = this.props.mxEvent.messageVisibility(); - switch (visibility.visible) { - case true: - throw new Error("renderPendingModerationMarker should only be applied to hidden messages"); - case false: - if (visibility.reason) { - text = _t("timeline|pending_moderation_reason", { reason: visibility.reason }); - } else { - text = _t("timeline|pending_moderation"); - } - break; - } - return {`(${text})`}; - } - - public componentDidMount(): void { - this.updateURLPreviewViewModel(); - } - - public render(): React.ReactNode { - if (this.props.editState) { - const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); - return isWysiwygComposerEnabled ? ( - - ) : ( - - ); - } - - const mxEvent = this.props.mxEvent; - const content = mxEvent.getContent(); - const isNotice = content.msgtype === MsgType.Notice; - const isEmote = content.msgtype === MsgType.Emote; - const isCaption = [MsgType.Image, MsgType.File, MsgType.Audio, MsgType.Video].includes( - content.msgtype as MsgType, - ); - const annotatedClassName = isEmote - ? "mx_EventTile_annotated mx_EventTile_annotatedInline" - : "mx_EventTile_annotated"; - - const willHaveWrapper = - this.props.replacingEventId || this.props.isSeeingThroughMessageHiddenForModeration || isEmote; - - let body = ( - - ); - - if (this.props.replacingEventId) { - body = ( -
- {body} - {this.renderEditedMarker()} -
- ); - } - if (this.props.isSeeingThroughMessageHiddenForModeration) { - body = ( -
- {body} - {this.renderPendingModerationMarker()} -
- ); - } - - if (this.props.highlightLink) { - body =
{body}; - } else if (content.data && typeof content.data["org.matrix.neb.starter_link"] === "string") { - body = ( - - {body} - - ); - } - - const urlPreviewWidget = ; - - if (isEmote) { - return ( -
- *  - - {mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender()} - -   - {body} - {urlPreviewWidget} -
- ); - } - if (isNotice) { - return ( -
- {body} - {urlPreviewWidget} -
- ); - } - if (isCaption) { - return ( -
- {body} - {urlPreviewWidget} -
- ); - } - return ( -
- {body} - {urlPreviewWidget} -
- ); - } -} - -export default function TextualBody(props: IBodyProps): React.ReactElement { - const [mediaVisible] = useMediaVisible(props.mxEvent); - const client = useMatrixClientContext(); - - const onUrlPreviewImageClicked = useCallback((preview: UrlPreview): void => { - if (!preview.image?.imageFull) { - // Should never get this far, but doesn't hurt to check. - return; - } - const params = { - src: preview.image.imageFull, - width: preview.image.width, - height: preview.image.height, - name: preview.title, - fileSize: preview.image.fileSize, - link: preview.link, - }; - Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true); - }, []); - - const vm = useCreateAutoDisposedViewModel( - () => - new UrlPreviewGroupViewModel({ - client, - mxEvent: props.mxEvent, - mediaVisible: mediaVisible, - onImageClicked: onUrlPreviewImageClicked, - visible: props.showUrlPreview ?? false, - }), - ); - - useEffect(() => { - (async () => { - try { - await vm.updateHidden(props.showUrlPreview ?? false, mediaVisible); - } catch (ex) { - logger.warn("UrlPreviewViewModel failed to updateHidden", ex); - } - })(); - }, [vm, props.showUrlPreview, mediaVisible]); - - const { previews } = useViewModel(vm); - - useEffect(() => { - if (previews.length === 0) { - return; - } - PosthogTrackers.instance.trackUrlPreview(props.mxEvent.getId()!, props.mxEvent.isEncrypted(), previews); - }, [props.mxEvent, previews]); - - return ; -} diff --git a/apps/web/src/components/views/messages/TextualBodyFactory.tsx b/apps/web/src/components/views/messages/TextualBodyFactory.tsx new file mode 100644 index 0000000000..d947bb982e --- /dev/null +++ b/apps/web/src/components/views/messages/TextualBodyFactory.tsx @@ -0,0 +1,217 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { type JSX, useContext, useEffect, useRef } from "react"; +import { logger as rootLogger } from "matrix-js-sdk/src/logger"; +import { MsgType } from "matrix-js-sdk/src/matrix"; +import { + EventContentBodyView, + TextualBodyView, + type TextualBodyContentElement, + type UrlPreview, + UrlPreviewGroupView, + useCreateAutoDisposedViewModel, + useViewModel, +} from "@element-hq/web-shared-components"; + +import { type IBodyProps } from "./IBodyProps"; +import RoomContext from "../../../contexts/RoomContext"; +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; +import { useMediaVisible } from "../../../hooks/useMediaVisible"; +import { TextualBodyViewModel } from "../../../viewmodels/room/timeline/event-tile/body/TextualBodyViewModel"; +import { EventContentBodyViewModel } from "../../../viewmodels/message-body/EventContentBodyViewModel"; +import { UrlPreviewGroupViewModel } from "../../../viewmodels/message-body/UrlPreviewGroupViewModel"; +import { getParentEventId } from "../../../utils/Reply"; +import Modal from "../../../Modal"; +import SettingsStore from "../../../settings/SettingsStore"; +import PosthogTrackers from "../../../PosthogTrackers"; +import ImageView from "../elements/ImageView"; +import EditMessageComposer from "../rooms/EditMessageComposer"; +import { EditWysiwygComposer } from "../rooms/wysiwyg_composer"; + +const logger = rootLogger.getChild("TextualBodyFactory"); + +function getTextualBodyClassName(msgtype: MsgType | undefined): string { + if (msgtype === MsgType.Notice) { + return "mx_MNoticeBody mx_EventTile_content"; + } + + if (msgtype === MsgType.Emote) { + return "mx_MEmoteBody mx_EventTile_content"; + } + + if ([MsgType.Image, MsgType.File, MsgType.Audio, MsgType.Video].includes(msgtype as MsgType)) { + return "mx_MTextBody mx_EventTile_caption"; + } + + return "mx_MTextBody mx_EventTile_content"; +} + +export function TextualBodyFactory(props: Readonly): JSX.Element { + const roomContext = useContext(RoomContext); + const client = useMatrixClientContext(); + const [mediaVisible] = useMediaVisible(props.mxEvent); + const content = props.mxEvent.getContent(); + const isEmote = content.msgtype === MsgType.Emote; + const willHaveWrapper = !!props.replacingEventId || !!props.isSeeingThroughMessageHiddenForModeration || isEmote; + const stripReply = !props.mxEvent.replacingEvent() && !!getParentEventId(props.mxEvent); + const contentRef = useRef(null); + + const textualBodyVm = useCreateAutoDisposedViewModel( + () => + new TextualBodyViewModel({ + id: props.id, + mxEvent: props.mxEvent, + highlightLink: props.highlightLink, + replacingEventId: props.replacingEventId, + isSeeingThroughMessageHiddenForModeration: props.isSeeingThroughMessageHiddenForModeration, + timelineRenderingType: roomContext.timelineRenderingType, + }), + ); + + const eventContentBodyVm = useCreateAutoDisposedViewModel( + () => + new EventContentBodyViewModel({ + as: willHaveWrapper ? "span" : "div", + includeDir: false, + mxEvent: props.mxEvent, + content, + stripReply, + linkify: true, + highlights: props.highlights, + renderTooltipsForAmbiguousLinks: true, + renderKeywordPills: true, + renderMentionPills: true, + renderCodeBlocks: true, + renderSpoilers: true, + client: roomContext.room?.client ?? client ?? null, + }), + ); + + const urlPreviewVm = useCreateAutoDisposedViewModel( + () => + new UrlPreviewGroupViewModel({ + client, + mxEvent: props.mxEvent, + mediaVisible, + onImageClicked: (preview: UrlPreview): void => { + if (!preview.image?.imageFull) { + return; + } + + Modal.createDialog( + ImageView, + { + src: preview.image.imageFull, + width: preview.image.width, + height: preview.image.height, + name: preview.title, + fileSize: preview.image.fileSize, + link: preview.link, + }, + "mx_Dialog_lightbox", + undefined, + true, + ); + }, + visible: props.showUrlPreview ?? false, + }), + ); + + const { previews } = useViewModel(urlPreviewVm); + + useEffect(() => { + textualBodyVm.setId(props.id); + }, [props.id, textualBodyVm]); + + useEffect(() => { + textualBodyVm.setEvent(props.mxEvent); + }, [props.mxEvent, textualBodyVm]); + + useEffect(() => { + textualBodyVm.setHighlightLink(props.highlightLink); + }, [props.highlightLink, textualBodyVm]); + + useEffect(() => { + textualBodyVm.setReplacingEventId(props.replacingEventId); + }, [props.replacingEventId, textualBodyVm]); + + useEffect(() => { + textualBodyVm.setIsSeeingThroughMessageHiddenForModeration(props.isSeeingThroughMessageHiddenForModeration); + }, [props.isSeeingThroughMessageHiddenForModeration, textualBodyVm]); + + useEffect(() => { + textualBodyVm.setTimelineRenderingType(roomContext.timelineRenderingType); + }, [roomContext.timelineRenderingType, textualBodyVm]); + + useEffect(() => { + eventContentBodyVm.setEventContent(props.mxEvent, content); + }, [content, props.mxEvent, eventContentBodyVm]); + + useEffect(() => { + eventContentBodyVm.setStripReply(stripReply); + }, [stripReply, eventContentBodyVm]); + + useEffect(() => { + eventContentBodyVm.setAs(willHaveWrapper ? "span" : "div"); + }, [willHaveWrapper, eventContentBodyVm]); + + useEffect(() => { + eventContentBodyVm.setHighlights(props.highlights); + }, [props.highlights, eventContentBodyVm]); + + useEffect(() => { + const eventElement = contentRef.current; + if (!eventElement) { + return; + } + + void urlPreviewVm.updateEventElement(eventElement).catch((error) => { + logger.warn("UrlPreviewViewModel failed to updateEventElement", error); + }); + }, [ + props.mxEvent, + props.highlights, + props.replacingEventId, + props.isSeeingThroughMessageHiddenForModeration, + urlPreviewVm, + ]); + + useEffect(() => { + void urlPreviewVm.updateHidden(props.showUrlPreview ?? false, mediaVisible).catch((error) => { + logger.warn("UrlPreviewViewModel failed to updateHidden", error); + }); + }, [props.showUrlPreview, mediaVisible, urlPreviewVm]); + + useEffect(() => { + if (previews.length === 0) { + return; + } + + PosthogTrackers.instance.trackUrlPreview(props.mxEvent.getId()!, props.mxEvent.isEncrypted(), previews); + }, [props.mxEvent, previews]); + + if (props.editState) { + const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); + + return isWysiwygComposerEnabled ? ( + + ) : ( + + ); + } + + return ( + } + bodyRef={contentRef} + urlPreviews={} + className={getTextualBodyClassName(content.msgtype as MsgType | undefined)} + /> + ); +} diff --git a/apps/web/src/components/views/right_panel/ExtensionsCard.tsx b/apps/web/src/components/views/right_panel/ExtensionsCard.tsx index 51cd5599e9..c3db44e501 100644 --- a/apps/web/src/components/views/right_panel/ExtensionsCard.tsx +++ b/apps/web/src/components/views/right_panel/ExtensionsCard.tsx @@ -191,7 +191,7 @@ const ExtensionsCard: React.FC = ({ room, onClose }) => { return ( {shouldShowComponent(UIComponent.AddIntegrations) && ( - )} diff --git a/apps/web/src/components/views/right_panel/VerificationPanel.tsx b/apps/web/src/components/views/right_panel/VerificationPanel.tsx index fa3a4dc4db..d369f98d3a 100644 --- a/apps/web/src/components/views/right_panel/VerificationPanel.tsx +++ b/apps/web/src/components/views/right_panel/VerificationPanel.tsx @@ -255,7 +255,7 @@ export default class VerificationPanel extends React.PureComponent - +

); } diff --git a/apps/web/src/components/views/right_panel/user_info/UserInfoHeaderVerificationView.tsx b/apps/web/src/components/views/right_panel/user_info/UserInfoHeaderVerificationView.tsx index eae5101a6f..7c581e26b7 100644 --- a/apps/web/src/components/views/right_panel/user_info/UserInfoHeaderVerificationView.tsx +++ b/apps/web/src/components/views/right_panel/user_info/UserInfoHeaderVerificationView.tsx @@ -40,7 +40,7 @@ export const UserInfoHeaderVerificationView: React.FC<{ diff --git a/apps/web/src/components/views/rooms/ThirdPartyMemberInfo.tsx b/apps/web/src/components/views/rooms/ThirdPartyMemberInfo.tsx index 5e90152673..fb440d98bd 100644 --- a/apps/web/src/components/views/rooms/ThirdPartyMemberInfo.tsx +++ b/apps/web/src/components/views/rooms/ThirdPartyMemberInfo.tsx @@ -117,7 +117,7 @@ export default class ThirdPartyMemberInfo extends React.Component {_t("user_info|admin_tools_section")} - diff --git a/apps/web/src/components/views/rooms/UserIdentityWarning.tsx b/apps/web/src/components/views/rooms/UserIdentityWarning.tsx index 12d1ee924f..d5ffdc512f 100644 --- a/apps/web/src/components/views/rooms/UserIdentityWarning.tsx +++ b/apps/web/src/components/views/rooms/UserIdentityWarning.tsx @@ -122,7 +122,7 @@ function warningBanner(
{avatar} {title} -
diff --git a/apps/web/src/components/views/settings/EventIndexPanel.tsx b/apps/web/src/components/views/settings/EventIndexPanel.tsx index 24acd0f8a6..4cd7a7d015 100644 --- a/apps/web/src/components/views/settings/EventIndexPanel.tsx +++ b/apps/web/src/components/views/settings/EventIndexPanel.tsx @@ -220,7 +220,12 @@ export default class EventIndexPanel extends React.Component

- + {_t("action|reset")}

diff --git a/apps/web/src/components/views/settings/Notifications.tsx b/apps/web/src/components/views/settings/Notifications.tsx index 15e530c57b..c52753907d 100644 --- a/apps/web/src/components/views/settings/Notifications.tsx +++ b/apps/web/src/components/views/settings/Notifications.tsx @@ -52,6 +52,7 @@ import { SettingsSubsectionHeading } from "./shared/SettingsSubsectionHeading"; import { SettingsSubsection } from "./shared/SettingsSubsection"; import { doesRoomHaveUnreadMessages } from "../../../Unread"; import SettingsFlag from "../elements/SettingsFlag"; +import { onSubmitPreventDefault } from "../../../utils/form.ts"; // TODO: this "view" component still has far too much application logic in it, // which should be factored out to other files. @@ -651,7 +652,7 @@ export default class Notifications extends React.PureComponent{masterSwitch}; } const emailSwitches = (this.state.threepids || []) @@ -669,19 +670,21 @@ export default class Notifications extends React.PureComponent - {masterSwitch} + + {masterSwitch} - + - {this.state.deviceNotificationsEnabled && ( - <> - - - - - )} + {this.state.deviceNotificationsEnabled && ( + <> + + + + + )} - {emailSwitches} + {emailSwitches} + ); } diff --git a/apps/web/src/components/views/settings/PowerLevelSelector.tsx b/apps/web/src/components/views/settings/PowerLevelSelector.tsx index 875aa5c999..ff971363e1 100644 --- a/apps/web/src/components/views/settings/PowerLevelSelector.tsx +++ b/apps/web/src/components/views/settings/PowerLevelSelector.tsx @@ -129,7 +129,7 @@ export function PowerLevelSelector({ })} - diff --git a/apps/web/src/components/views/settings/encryption/RecoveryPanel.tsx b/apps/web/src/components/views/settings/encryption/RecoveryPanel.tsx index 9c7f12efc8..bc56dd84a4 100644 --- a/apps/web/src/components/views/settings/encryption/RecoveryPanel.tsx +++ b/apps/web/src/components/views/settings/encryption/RecoveryPanel.tsx @@ -57,14 +57,14 @@ export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps): break; case "missing_recovery_key": content = ( - ); break; case "good": content = ( - ); diff --git a/apps/web/src/components/views/settings/encryption/RecoveryPanelOutOfSync.tsx b/apps/web/src/components/views/settings/encryption/RecoveryPanelOutOfSync.tsx index 3d8dcca428..35cfc7ef38 100644 --- a/apps/web/src/components/views/settings/encryption/RecoveryPanelOutOfSync.tsx +++ b/apps/web/src/components/views/settings/encryption/RecoveryPanelOutOfSync.tsx @@ -62,11 +62,11 @@ export function RecoveryPanelOutOfSync({ data-testid="recoveryPanel" >
-
diff --git a/apps/web/src/components/views/settings/tabs/user/NotificationUserSettingsTab.tsx b/apps/web/src/components/views/settings/tabs/user/NotificationUserSettingsTab.tsx index 6d6c25e122..b63e790736 100644 --- a/apps/web/src/components/views/settings/tabs/user/NotificationUserSettingsTab.tsx +++ b/apps/web/src/components/views/settings/tabs/user/NotificationUserSettingsTab.tsx @@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { Form } from "@vector-im/compound-web"; import { Features } from "../../../../../settings/Settings"; import SettingsStore from "../../../../../settings/SettingsStore"; @@ -22,20 +21,13 @@ export default class NotificationUserSettingsTab extends React.Component { return ( - { - evt.preventDefault(); - evt.stopPropagation(); - }} - > - {newNotificationSettingsEnabled ? ( - - ) : ( - - - - )} - + {newNotificationSettingsEnabled ? ( + + ) : ( + + + + )} ); } diff --git a/apps/web/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx b/apps/web/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx index 8aeae9234a..caa379ba2e 100644 --- a/apps/web/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx +++ b/apps/web/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx @@ -184,14 +184,7 @@ const SpaceSettingsVisibilityTab: React.FC = ({ matrixClient: cli, space - { - evt.preventDefault(); - evt.stopPropagation(); - }} - > - {addressesSection} - + {addressesSection} ); diff --git a/apps/web/src/components/views/toasts/GenericToast.tsx b/apps/web/src/components/views/toasts/GenericToast.tsx index 75149cf87c..2be73f2af4 100644 --- a/apps/web/src/components/views/toasts/GenericToast.tsx +++ b/apps/web/src/components/views/toasts/GenericToast.tsx @@ -56,7 +56,7 @@ const GenericToast: React.FC> = ({ onClick={onSecondaryClick} kind={destructive === "secondary" ? "destructive" : "secondary"} Icon={SecondaryIcon} - size="sm" + size="md" > {secondaryLabel} @@ -65,7 +65,7 @@ const GenericToast: React.FC> = ({ onClick={onPrimaryClick} kind={destructive === "primary" ? "destructive" : "primary"} Icon={PrimaryIcon} - size="sm" + size="md" > {primaryLabel} diff --git a/apps/web/src/device-listener/DeviceListenerCurrentDevice.ts b/apps/web/src/device-listener/DeviceListenerCurrentDevice.ts index 0460d82366..cc9bef3d32 100644 --- a/apps/web/src/device-listener/DeviceListenerCurrentDevice.ts +++ b/apps/web/src/device-listener/DeviceListenerCurrentDevice.ts @@ -24,7 +24,6 @@ import { showToast as showSetupEncryptionToast, } from "../toasts/SetupEncryptionToast"; import { isSecretStorageBeingAccessed } from "../SecurityManager"; -import { asyncSomeParallel } from "../utils/arrays"; const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; @@ -271,10 +270,12 @@ export class DeviceListenerCurrentDevice { if (newState === "ok" || this.dismissedThisDeviceToast) { hideSetupEncryptionToast(); - } else if (await this.shouldShowSetupEncryptionToast()) { + } else if (!isSecretStorageBeingAccessed()) { showSetupEncryptionToast(newState); } else { - logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false"); + // If we're in the middle of a secret storage operation, we're likely + // modifying the state involved here, so don't add new toasts to setup. + logSpan.info("Device is not yet ready, but secret storage is being accessed, so not showing toast."); } } @@ -386,23 +387,6 @@ export class DeviceListenerCurrentDevice { return this.keyBackupInfo; } - /** - * Is the user in at least one encrypted room? - */ - private async shouldShowSetupEncryptionToast(): Promise { - // If we're in the middle of a secret storage operation, we're likely - // modifying the state involved here, so don't add new toasts to setup. - if (isSecretStorageBeingAccessed()) return false; - - // Show setup toasts once the user is in at least one encrypted room. - const cryptoApi = this.client.getCrypto(); - if (!cryptoApi) return false; - - return await asyncSomeParallel(this.client.getRooms(), ({ roomId }) => - cryptoApi.isEncryptionEnabledInRoom(roomId), - ); - } - /** * Is key backup enabled? Use a cached answer if we have one. */ diff --git a/apps/web/src/dispatcher/payloads/ComposerInsertPayload.ts b/apps/web/src/dispatcher/payloads/ComposerInsertPayload.ts index 9712a8303a..597db9712e 100644 --- a/apps/web/src/dispatcher/payloads/ComposerInsertPayload.ts +++ b/apps/web/src/dispatcher/payloads/ComposerInsertPayload.ts @@ -17,7 +17,7 @@ export enum ComposerType { interface IBaseComposerInsertPayload extends ActionPayload { action: Action.ComposerInsert; - timelineRenderingType: TimelineRenderingType; + timelineRenderingType?: TimelineRenderingType; // undefined if this should just use the current in-focus type. composerType?: ComposerType; // falsy if should be re-dispatched to the correct composer } diff --git a/apps/web/src/hooks/useCall.ts b/apps/web/src/hooks/useCall.ts index ffd5272b68..253c50da99 100644 --- a/apps/web/src/hooks/useCall.ts +++ b/apps/web/src/hooks/useCall.ts @@ -50,15 +50,7 @@ export const useParticipantCount = (call: Call | null): number => { }, [participants]); }; -export const useParticipatingMembers = (call: Call): RoomMember[] => { +export const useParticipatingMembers = (call: Call | null): RoomMember[] => { const participants = useParticipants(call); - - return useMemo(() => { - const members: RoomMember[] = []; - for (const [member, devices] of participants) { - // Repeat the member for as many devices as they're using - for (let i = 0; i < devices.size; i++) members.push(member); - } - return members; - }, [participants]); + return useMemo(() => [...participants.keys()], [participants]); }; diff --git a/apps/web/src/i18n/strings/cs.json b/apps/web/src/i18n/strings/cs.json index 3f291cac32..b2c02f1358 100644 --- a/apps/web/src/i18n/strings/cs.json +++ b/apps/web/src/i18n/strings/cs.json @@ -370,7 +370,7 @@ "fallback_button": "Zahájit autentizaci", "mas_cross_signing_reset_cta": "Přejděte na svůj účet", "mas_cross_signing_reset_description": "Chystáte se přejít na svůj účet %(serverName)s, abyste obnovili svou digitální identitu. Jakmile dokončíte obnovení svého účtu, vraťte se sem a klikněte na Zkusit znovu.", - "mas_cross_signing_reset_title": "Přejděte na svůj účet a obnovte svou identitu", + "mas_cross_signing_reset_title": "Přejděte do svého účtu a resetujte si digitální identitu.", "msisdn": "Na číslo %(msisdn)s byla odeslána textová zpráva", "msisdn_token_incorrect": "Neplatný token", "msisdn_token_prompt": "Prosím zadejte kód z této zprávy:", @@ -681,6 +681,12 @@ "unfederated_label_default_on": "Toto můžete deaktivovat, pokud bude místnost použita pro spolupráci s externími týmy, které mají svůj vlastní domovský server. Toto nelze později změnit.", "unsupported_version": "Server nepodporuje určenou verzi místnosti." }, + "create_section_dialog": { + "create_section": "Vytvořit sekci", + "description": "Sekce určeny pouze pro vás", + "label": "Název sekce", + "title": "Vytvořte sekci" + }, "create_space": { "add_details_prompt": "Přidejte nějaké podrobnosti, aby ho lidé lépe rozpoznali.", "add_details_prompt_2": "Tyto údaje můžete kdykoli změnit.", @@ -749,6 +755,7 @@ "category_other": "Další možnosti", "category_room": "Místnost", "caution_colon": "Pozor:", + "checking_sticky_events_support": "Zjišťování, zda jsou podporovány trvalé události...", "client_versions": "Verze klienta", "crypto": { "4s_public_key_in_account_data": "v datech účtu", @@ -771,7 +778,7 @@ "cross_signing_public_keys_on_device_status": "Veřejné klíče pro křížový podpis:", "cross_signing_ready": "Křížové podepisování je připraveno k použití.", "cross_signing_status": "Stav křížového podepisování:", - "cross_signing_untrusted": "Váš účet má v bezpečném úložišti identitu pro křížový podpis, ale v této relaci jí zatím nevěříte.", + "cross_signing_untrusted": "Váš účet má v tajném úložišti digitální identitu, ale v této relaci mu zatím nedůvěřujete.", "crypto_not_available": "Kryptografický modul není k dispozici", "device_id": "ID zařízení", "key_backup_active_version": "Verze aktivní zálohy:", @@ -807,13 +814,18 @@ "edit_setting": "Upravit nastavení", "edit_values": "Upravit hodnoty", "empty_string": "", + "error_sticky_duration_must_be_a_number": "stickyDuration musí být číslo", + "error_sticky_duration_out_of_range": "Hodnota stickyDuration musí být mezi 0 a 36000 milisekundami (1h)", "event_content": "Obsah události", "event_id": "ID události: %(eventId)s", "event_sent": "Událost odeslána!", "event_type": "Typ události", + "expired": "Platnost vypršela", + "expires_in": "Platnost vyprší za", "explore_account_data": "Prozkoumat údaje o účtu", "explore_room_account_data": "Prozkoumat údaje o účtu místnosti", "explore_room_state": "Prozkoumat stav místnosti", + "explore_sticky_state": "Prozkoumat stav", "failed_to_find_widget": "Při hledání tohoto widgetu došlo k chybě.", "failed_to_load": "Nepodařilo se načíst.", "failed_to_save": "Nepodařilo se uložit nastavení.", @@ -822,11 +834,12 @@ "invalid_device_key_id": "Neplatné ID klíče zařízení", "invalid_json": "Nevypadá to jako platný JSON.", "level": "Úroveň", - "low_bandwidth_mode": "Režim malé šířky pásma", - "low_bandwidth_mode_description": "Vyžaduje kompatibilní domovský server.", + "low_bandwidth_mode": "Vypnout funkce náročné na šířku pásma", + "low_bandwidth_mode_description": "Zakáže šifrování, informace o přítomnosti, avatary, potvrzení o přečtení a oznámení o psaní", "main_timeline": "Hlavní časová osa", "manual_device_verification": "Ruční ověření zařízení", "no_receipt_found": "Žádné potvrzení o přečtení", + "no_sticky_events": "V této místnosti nejsou žádné trvalé události.", "notification_state": "Stav oznámení je %(notificationState)s", "notifications_debug": "Ladění oznámení", "number_of_users": "Počet uživatelů", @@ -853,6 +866,7 @@ "send_custom_account_data_event": "Odeslat vlastní událost s údaji o účtu", "send_custom_room_account_data_event": "Odeslat vlastní událost s údaji o účtu místnosti", "send_custom_state_event": "Odeslat vlastní stavovou událost", + "send_custom_sticky_event": "Odeslat vlastní trvalou událost", "send_custom_timeline_event": "Odeslat vlastní událost na časové ose", "server_info": "Informace o serveru", "server_versions": "Verze serveru", @@ -872,6 +886,9 @@ "other": "<%(count)s mezer>" }, "state_key": "Stavový klíč", + "sticky_duration": "Trvání události (ms)", + "sticky_events_not_supported": "Váš domovský server zatím nepodporuje trvalé události.", + "sticky_key": "Trvalý klíč", "thread_root_id": "ID kořenového vlákna: %(threadRootId)s", "threads_timeline": "Časová osa vláken", "title": "Nástroje pro vývojáře", @@ -889,10 +906,10 @@ "user_read_up_to_private_ignore_synthetic": "Uživatel čte až do (m.read.private;ignoreSynthetic): ", "user_room_membership": "Členství: %(membership)s", "user_verification_status": { - "identity_changed": "Stav ověření: Neověřeno a identita změněna", + "identity_changed": "Stav ověření: Neověřeno a změněna digitální identita", "unverified": "Stav ověření: Neověřeno", "verified": "Stav ověření: Ověřeno", - "was_verified": "Stav ověření: Bylo ověřeno, ale identita se změnila." + "was_verified": "Stav ověření: Bylo ověřeno, ale digitální identita se změnila." }, "users": "Uživatelé", "value": "Hodnota", @@ -958,7 +975,7 @@ "event_shield_reason_unverified_identity": "Šifrováno neověřeným uživatelem.", "export_unsupported": "Váš prohlížeč nepodporuje požadovaná kryptografická rozšíření", "forgot_recovery_key": "Zapomněli jste klíč pro obnovení?", - "identity_needs_reset_description": "Abyste si zajistili přístup k historii zpráv, musíte resetovat svou kryptografickou identitu.", + "identity_needs_reset_description": "Abyste si zajistili přístup k historii chatu, musíte si resetovat digitální identitu.", "import_invalid_keyfile": "Neplatný soubor s klíčem %(brand)s", "import_invalid_passphrase": "Kontrola ověření selhala: špatné heslo?", "key_storage_out_of_sync": "Vaše úložiště klíčů není synchronizováno.", @@ -987,13 +1004,13 @@ "warning": "Pokud jste způsob obnovy neodstranili vy, mohou se pokoušet k vašemu účtu dostat útočníci. Změňte si raději ihned heslo a nastavte nový způsob obnovy v Nastavení." }, "set_up_recovery": "Zálohujte své chaty", - "set_up_recovery_toast_description": "Vygenerujte klíč pro obnovení, který lze použít k obnovení historie šifrovaných zpráv v případě, že ztratíte přístup k zařízením.", + "set_up_recovery_toast_description": "Vaše chaty jsou automaticky zálohovány pomocí koncového šifrování. Chcete-li tuto zálohu obnovit a zachovat si svou digitální identitu v případě, že ztratíte přístup ke všem svým zařízením, budete potřebovat svůj klíč pro obnovení.", "set_up_toast_title": "Nastavení zabezpečené zálohy", "setup_secure_backup": { "explainer": "Před odebráním tohoto zařízení si zálohujte klíče, abyste o ně nepřišli." }, "turn_on_key_storage": "Zapnout úložiště klíčů", - "turn_on_key_storage_description": "Bezpečně uložte svou kryptografickou identitu a klíče zpráv na serveru. To vám umožní zobrazit historii zpráv na všech nových zařízeních.", + "turn_on_key_storage_description": "Díky tomu budete moci zobrazit historii chatu na jakémkoli novém zařízení a je to nutné pro zálohování chatů a digitální identity.", "udd": { "interactive_verification_button": "Interaktivní ověření pomocí emoji", "other_ask_verify_text": "Požádejte tohoto uživatele, aby ověřil svou relaci, nebo jí níže můžete ověřit manuálně.", @@ -1017,7 +1034,7 @@ "complete_description": "Uživatel úspěšně ověřen.", "complete_title": "Ověřeno!", "confirm_identity_description": "Ověřte toto zařízení a nastavte zabezpečené zasílání zpráv.", - "confirm_identity_title": "Potvrďte svou totožnost", + "confirm_identity_title": "Potvrďte svou digitální identitu", "confirm_the_emojis": "Zkontrolujte, zda se níže uvedené emotikony shodují s emotikony zobrazenými na vašem druhém zařízení.", "error_starting_description": "Nepodařilo se zahájit chat s druhým uživatelem.", "error_starting_title": "Chyba při zahájení ověření", @@ -1101,8 +1118,8 @@ "waiting_other_user": "Čekám až nás %(displayName)s ověří…" }, "verification_requested_toast_title": "Žádost ověření", - "verified_identity_changed": "Ověřená identita uživatele %(displayName)s (%(userId)s) se změnila. Další informace", - "verified_identity_changed_no_displayname": "Ověřená identita uživatele %(userId)s se změnila. Další informace", + "verified_identity_changed": "Digitální identita uživatele %(displayName)s (%(userId)s) byla resetována. Další informace", + "verified_identity_changed_no_displayname": "Digitální identita uživatele %(userId)s byla resetována. Další informace", "verify_toast_description": "Od konce dubna 2026 nebudou neověřená zařízení moci odesílat ani přijímat zprávy. Další informace", "verify_toast_title": "Ověřit toto zařízení", "withdraw_verification_action": "Zrušit ověření" @@ -1551,9 +1568,7 @@ "render_reaction_images_description": "Někdy se označují jako \"vlastní emoji\".", "report_to_moderators": "Nahlásit moderátorům", "report_to_moderators_description": "V místnostech, které podporují moderování, můžete pomocí tlačítka \"Nahlásit\" nahlásit zneužití moderátorům místnosti.", - "share_history_on_invite": "Sdílet šifrovanou historii s novými členy", - "share_history_on_invite_description": "Při pozvání uživatele do šifrované místnosti, u které je viditelnost historie nastavena na „sdílená“, sdílet šifrovanou historii s tímto uživatelem a přijmout šifrovanou historii, když jste pozváni do takové místnosti.", - "share_history_on_invite_warning": "Tato funkce je EXPERIMENTÁLNÍ a nejsou v ní implementována všechna bezpečnostní opatření. Neaktivujte ji na produkčních účtech.", + "room_list_sections": "Sekce seznamu místností", "sliding_sync": "Režim klouzavé synchronizace", "sliding_sync_description": "V aktivním vývoji, nelze zakázat.", "sliding_sync_disabled_notice": "Pro deaktivaci se znovu přihlaste", @@ -1801,7 +1816,6 @@ "restricted": "Omezené" }, "powered_by_matrix": "Běží na Matrixu", - "powered_by_matrix_with_logo": "Decentralizovaný, šifrovaný chat a spolupráce na platformě $matrixLogo", "presence": { "away": "Pryč", "busy": "Zaneprázdněný", @@ -2154,6 +2168,11 @@ "one": "Momentálně se odstraňují zprávy v %(count)s místnosti", "other": "Momentálně se odstraňují zprávy v %(count)s místnostech" }, + "section": { + "chats": "Chaty", + "favourites": "Oblíbené", + "low_priority": "Nízká priorita" + }, "show_less": "Zobrazit méně", "show_n_more": { "other": "Zobrazit %(count)s dalších", @@ -2351,6 +2370,7 @@ "join_rule_invite_description": "Připojit se mohou pouze pozvané osoby.", "join_rule_knock": "Požádat o vstup", "join_rule_knock_description": "Lidé nemohou vstoupit, pokud jim není povolen přístup.", + "join_rule_public": "Kdokoli", "join_rule_public_description": "Vstoupit může kdokoli.", "join_rule_restricted": "Členové prostoru", "join_rule_restricted_description": "Kdokoli v autorizovaných prostorách se může připojit bez pozvání. Spravovat prostory", @@ -2527,10 +2547,10 @@ "breadcrumb_page": "Obnovit šifrování", "breadcrumb_second_description": "Ztratíte veškerou historii zpráv, která je uložena pouze na serveru", "breadcrumb_third_description": "Budete muset znovu ověřit všechna svá stávající zařízení a kontakty", - "breadcrumb_title": "Opravdu chcete obnovit svou identitu?", - "breadcrumb_title_cant_confirm": "Musíte resetovat svou totožnost", - "breadcrumb_title_forgot": "Zapomněli jste klíč pro obnovení? Budete muset obnovit svou identitu.", - "breadcrumb_title_sync_failed": "Synchronizace úložiště klíčů se nezdařila. Musíte obnovit svou identitu.", + "breadcrumb_title": "Opravdu chcete resetovat svou digitální identitu?", + "breadcrumb_title_cant_confirm": "Musíte resetovat svou digitální identitu", + "breadcrumb_title_forgot": "Zapomněli jste klíč pro obnovení? Budete muset resetovat svou digitální identitu.", + "breadcrumb_title_sync_failed": "Synchronizace úložiště klíčů se nezdařilo. Je třeba resetovat vaši digitální identitu.", "breadcrumb_warning": "Udělejte to pouze v případě, že se domníváte, že váš účet byl napaden.", "details_title": "Podrobnosti o šifrování", "do_not_close_warning": "Nezavírejte toto okno, dokud není resetování dokončeno", @@ -2552,7 +2572,7 @@ "confirm": "Smazat úložiště klíčů", "description": "Smazáním úložiště klíčů odstraníte ze serveru vaši kryptografickou identitu a klíče zpráv a vypnete následující funkce zabezpečení:", "list_first": "Na nových zařízeních nebudete mít šifrovanou historii zpráv", - "list_second": "Pokud se všude odhlásíte z %(brand)s, ztratíte přístup ke svým zašifrovaným zprávám", + "list_second": "Pokud nebudete přihlášeni k žádnému zařízení, ztratíte přístup ke svým šifrovaným zprávám.", "title": "Opravdu chcete vypnout úložiště klíčů a smazat jej?" }, "device_not_verified_button": "Ověřte toto zařízení", @@ -2561,7 +2581,7 @@ "dialog_title": "Nastavení: Šifrování", "key_storage": { "allow_key_storage": "Povolit úložiště klíčů", - "description": "Uložte svou kryptografickou identitu a klíče zpráv bezpečně na serveru. To vám umožní zobrazit historii zpráv na všech nových zařízeních. Další informace", + "description": "Díky tomu budete moci zobrazit historii chatu na jakémkoli novém zařízení a je to nutné pro zálohování chatů a digitální identity. Další informace", "title": "Úložiště klíčů" }, "recovery": { @@ -2571,14 +2591,14 @@ "change_recovery_key": "Změnit klíč pro obnovení", "change_recovery_key_description": "Zapište si tento nový klíč pro obnovení na bezpečné místo. Poté klikněte na tlačítko Pokračovat a potvrďte změnu.", "change_recovery_key_title": "Změnit klíč pro obnovení?", - "description": "Pokud jste ztratili všechna stávající zařízení, obnovte kryptografickou identitu a historii zpráv pomocí klíče pro obnovení.", + "description": "Vaše chaty jsou automaticky zálohovány pomocí koncového šifrování. Chcete-li tuto zálohu obnovit a zachovat si svou digitální identitu v případě, že ztratíte přístup ke všem svým zařízením, budete potřebovat svůj klíč pro obnovení.", "enter_key_error": "Zadaný klíč pro obnovení není správný.", "enter_recovery_key": "Zadejte klíč pro obnovení", "forgot_recovery_key": "Zapomněli jste klíč pro obnovení?", "key_storage_warning": "Vaše úložiště klíčů není synchronizováno. Klikněte na jedno z níže uvedených tlačítek a problém vyřešte.", "save_key_description": "Nesdílejte ho s nikým!", "save_key_title": "Klíč pro obnovení", - "set_up_recovery": "Nastavení obnovy", + "set_up_recovery": "Získat klíč pro obnovení", "set_up_recovery_confirm_button": "Dokončete nastavení", "set_up_recovery_confirm_description": "Zadejte obnovovací klíč zobrazený na předchozí obrazovce a dokončete nastavení obnovy.", "set_up_recovery_confirm_title": "Pro potvrzení zadejte klíč pro obnovení", @@ -2586,7 +2606,7 @@ "set_up_recovery_save_key_description": "Tento klíč pro obnovení si zapište na bezpečné místo, jako je správce hesel, šifrovaná poznámka nebo fyzický trezor.", "set_up_recovery_save_key_title": "Uložte si klíč pro obnovení na bezpečném místě", "set_up_recovery_secondary_description": "Po kliknutí na Pokračovat vám vygenerujeme obnovovací klíč.", - "title": "Obnovení" + "title": "Zálohování" }, "title": "Šifrování" }, @@ -2679,7 +2699,8 @@ "unable_to_load_msisdns": "Nelze načíst telefonní čísla", "username": "Uživatelské jméno" }, - "inline_url_previews_default": "Nastavit povolení náhledů URL adres jako výchozí", + "inline_url_previews_default": "Povolit náhledy", + "inline_url_previews_encrypted": "Povolit náhledy v šifrovaných místnostech", "insert_trailing_colon_mentions": "Vložit dvojtečku za zmínku o uživateli na začátku zprávy", "invite_controls": { "default_label": "Povolit uživatelům pozvat vás do místností" @@ -2817,6 +2838,8 @@ "enable_tray_icon": "Zobrazit ikonu v oznamovací oblasti a minimalizivat při zavření okna", "keyboard_heading": "Klávesové zkratky", "keyboard_view_shortcuts_button": "Pro zobrazení všech klávesových zkratek, klikněte zde.", + "link_previews_description": "Zobrazuje informace o odkazech pod zprávami", + "link_previews_heading": "Náhledy odkazů", "media_heading": "Obrázky, GIFy a videa", "presence_description": "Sdílejte své aktivity a stav s ostatními.", "publish_timezone": "Zveřejnit časové pásmo na veřejném profilu", @@ -3144,7 +3167,7 @@ "view": "Zobrazí místnost s danou adresou", "whois": "Zobrazuje informace o uživateli" }, - "sliding_sync_legacy_no_longer_supported": "Starší klouzavá synchronizace již není podporována: odhlaste se a znovu přihlaste, abyste povolili nový příznak klouzavé synchronizace", + "sliding_sync_legacy_no_longer_supported": "Starší klouzavá synchronizace již není podporována: pro aktivaci nové klouzavé synchronizace se prosím znovu přihlaste.", "space": { "add_existing_room_space": { "create": "Chcete místo toho přidat novou místnost?", @@ -3475,6 +3498,8 @@ "accepted_invite": "%(targetName)s přijal(a) pozvání", "ban": "%(senderName)s vykázal(a) uživatele", "ban_reason": "%(senderName)s vykázal(a) uživatele: %(reason)s", + "ban_reason_spoiler": "%(senderName)s vykázal(a) :%(reason)s", + "ban_spoiler": "%(senderName)s vykázal(a) ", "change_avatar": "%(senderName)s změnil(a) svůj profilový obrázek", "change_name": "%(oldDisplayName)s si změnil(a) zobrazované jméno na %(displayName)s", "change_name_avatar": "%(oldDisplayName)s změnil(a) své zobrazované jméno a profilový obrázek", @@ -3965,7 +3990,6 @@ "you_are_presenting": "Prezentujete" }, "web_default_device_name": "%(appName)s: %(browserName)s na %(osName)s", - "welcome_to_element": "Vítá vás Element", "widget": { "added_by": "Widget přidal", "capabilities_dialog": { @@ -3987,6 +4011,7 @@ "change_name_this_room": "Změňte název této místnosti", "change_topic_active_room": "Změnit téma vaší aktivní místnosti", "change_topic_this_room": "Změnit téma této místnosti", + "download_file": "Stáhnout soubory z úložiště médií", "receive_membership_active_room": "Zjistěte, kdy se lidé připojí, odejdou nebo jsou pozváni do vaší aktivní místnosti", "receive_membership_this_room": "Zjistěte, kdy se lidé připojí, odejdou nebo jsou pozváni do této místnosti", "remove_ban_invite_leave_active_room": "Odebrat, vykázat nebo pozvat lidi do vaší aktivní místnosti a donutit vás ji opustit", diff --git a/apps/web/src/i18n/strings/cy.json b/apps/web/src/i18n/strings/cy.json index 00df88dfcd..9bac8f0faf 100644 --- a/apps/web/src/i18n/strings/cy.json +++ b/apps/web/src/i18n/strings/cy.json @@ -1563,9 +1563,6 @@ "render_reaction_images_description": "Weithiau'n cael eu hadnabod fel \"emojis cyfaddas\".", "report_to_moderators": "Adrodd i cymedrolwyr", "report_to_moderators_description": "Mewn ystafelloedd sy'n cefnogi cymedroli, bydd y botwm “Adrodd” yn gadael i chi adrodd ar gam-drin i gymedrolwyr ystafelloedd.", - "share_history_on_invite": "Rhannwch hanes wedi'i amgryptio gydag aelodau newydd", - "share_history_on_invite_description": "Wrth wahodd defnyddiwr i ystafell wedi'i hamgryptio sydd â gwelededd hanes wedi'i osod i \"rhannu\", rhannwch hanes wedi'i amgryptio gyda'r defnyddiwr hwnnw, a derbyniwch hanes wedi'i amgryptio pan gewch eich gwahodd i ystafell o'r fath.", - "share_history_on_invite_warning": "Mae'r nodwedd hon yn ARBROFOL ac nid yw pob rhagofal diogelwch wedi'i weithredu. Peidiwch â'i galluogi ar gyfrifon cynhyrchu.", "sliding_sync": "Modd Cydweddu Llithro", "sliding_sync_description": "Wrthi'n datblygu, nid oes modd ei analluogi.", "sliding_sync_disabled_notice": "Allgofnodwch a mewngofnodi i analluogi", @@ -1801,7 +1798,6 @@ "restricted": "Cyfyngedig" }, "powered_by_matrix": "Wedi'i bweru gan Matrix", - "powered_by_matrix_with_logo": "Sgwrs ddatganoledig, wedi'i hamgryptio & cydweithrediad wedi'i bweru gan $matrixLogo", "presence": { "away": "I ffwrdd", "busy": "Prysur", @@ -3957,7 +3953,6 @@ "you_are_presenting": "Rydych chi'n cyflwyno" }, "web_default_device_name": "%(appName)s: %(browserName)s ar %(osName)s", - "welcome_to_element": "Croeso i Element", "widget": { "added_by": "Ychwanegwyd teclyn gan", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/da.json b/apps/web/src/i18n/strings/da.json index d10c9d15c7..5d439bf984 100644 --- a/apps/web/src/i18n/strings/da.json +++ b/apps/web/src/i18n/strings/da.json @@ -3486,7 +3486,6 @@ "you_are_presenting": "Du præsenterer" }, "web_default_device_name": "%(appName)s: %(browserName)s på %(osName)s", - "welcome_to_element": "Velkommen til Element", "widget": { "added_by": "Widget tilføjet af", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/de_DE.json b/apps/web/src/i18n/strings/de_DE.json index 945fb8e718..05eb83b9ba 100644 --- a/apps/web/src/i18n/strings/de_DE.json +++ b/apps/web/src/i18n/strings/de_DE.json @@ -1551,9 +1551,6 @@ "render_reaction_images_description": "Werden manchmal auch als „benutzerdefinierte Emojis“ bezeichnet.", "report_to_moderators": "An Moderatoren melden", "report_to_moderators_description": "In Chats, die Moderation unterstützen, kannst du mit der Schaltfläche „Melden“ missbräuchliche Verwendung an die Moderatoren melden.", - "share_history_on_invite": "Teile den verschlüsselten Nachrichtenverlauf mit neuen Mitgliedern", - "share_history_on_invite_description": "Wenn du einen Nutzer in einen Chat einlädst, bei dem die die Sichtbarkeit der Historie auf \"geteilt\" eingestellt ist, wird der verschlüsselte Nachrichtenverlauf mit diesem Nutzer geteilt. Du akzeptierst damit auch, einen solchen verschlüsselten Nachrichtenverlauf zu empfangen, wenn du selbst in einen solchen Chat eingeladen wirst.", - "share_history_on_invite_warning": "Diese Funktion ist EXPERIMENTELL und nicht alle Sicherheitsmaßnahmen sind aktiviert. Bitte nicht auf Konten verwenden, die für den Produktions-Betrieb gedacht sind.", "sliding_sync": "Sliding-Sync-Modus", "sliding_sync_description": "In aktiver Entwicklung, kann nicht deaktiviert werden. Derzeit nicht mit Element Call kompatibel.", "sliding_sync_disabled_notice": "Zum Deaktivieren, melde dich ab und erneut an", @@ -1798,7 +1795,6 @@ "restricted": "Eingeschränkt" }, "powered_by_matrix": "Betrieben mit Matrix", - "powered_by_matrix_with_logo": "Dezentraler, verschlüsselter Chat & Zusammenarbeit auf Basis von $matrixLogo", "presence": { "away": "Abwesend", "busy": "Beschäftigt", @@ -3950,7 +3946,6 @@ "you_are_presenting": "Du präsentierst" }, "web_default_device_name": "%(appName)s: %(browserName)s auf %(osName)s", - "welcome_to_element": "Willkommen bei Element", "widget": { "added_by": "Widget hinzugefügt von", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/el.json b/apps/web/src/i18n/strings/el.json index 8565897d1f..ac5f9aa888 100644 --- a/apps/web/src/i18n/strings/el.json +++ b/apps/web/src/i18n/strings/el.json @@ -1435,7 +1435,6 @@ "restricted": "Περιορισμένο/η" }, "powered_by_matrix": "Με την υποστήριξη του Matrix", - "powered_by_matrix_with_logo": "Αποκεντρωμένη, κρυπτογραφημένη συνομιλία και συνεργασία χρησιμοποιώντας το $matrixLogo", "presence": { "away": "Απομακρυσμένος", "busy": "Απασχολημένος", @@ -3169,7 +3168,6 @@ "voice_call": "Φωνητική κλήση", "you_are_presenting": "Παρουσιάζετε" }, - "welcome_to_element": "Καλώς ήλθατε στο Element", "widget": { "added_by": "Μικροεοεφαρμογή προστέθηκε από", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index ac4b3d3e9a..62104e0c61 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -42,7 +42,7 @@ "copy_link": "Copy link", "create": "Create", "create_a_room": "Create a room", - "create_account": "Create Account", + "create_account": "Create account", "decline": "Decline", "decline_and_block": "Decline and block", "decline_invite": "Decline invite", @@ -588,7 +588,6 @@ "video": "Video", "video_room": "Video room", "view_message": "View message", - "voice": "Voice", "warning": "Warning" }, "composer": { @@ -681,6 +680,14 @@ "unfederated_label_default_on": "You might disable this if the room will be used for collaborating with external teams who have their own homeserver. This cannot be changed later.", "unsupported_version": "The server does not support the room version specified." }, + "create_section_dialog": { + "create_section": "Create section", + "description": "Sections are only for you", + "edit_section": "Edit section", + "label": "Section name", + "title": "Create a section", + "title_edition": "Edit a section" + }, "create_space": { "add_details_prompt": "Add some details to help people recognise it.", "add_details_prompt_2": "You can change these anytime.", @@ -1362,6 +1369,14 @@ "impossible_dialog_title": "Integrations not allowed" }, "invite": { + "confirm_unknown_users": { + "invite_subtitle": "You currently don't have any chats with these contacts. Confirm inviting them to this room before continuing.", + "invite_title": "Invite new contacts to this room?", + "start_chat_subtitle_multiple_users": "You currently don't have any chats with these people. Confirm inviting them before continuing.", + "start_chat_subtitle_one_user": "You currently don't have any chats with this person. Confirm inviting them before continuing.", + "start_chat_title_multiple_users": "Start a chat with these new contacts?", + "start_chat_title_one_user": "Start a chat with this new contact?" + }, "email_caption": "Invite by email", "email_limit_one": "Invites by email can only be sent one at a time", "email_use_default_is": "Use an identity server to invite by email. Use the default (%(defaultIdentityServerName)s) or manage in Settings.", @@ -1563,9 +1578,6 @@ "report_to_moderators": "Report to moderators", "report_to_moderators_description": "In rooms that support moderation, the “Report” button will let you report abuse to room moderators.", "room_list_sections": "Room list sections", - "share_history_on_invite": "Share encrypted history with new members", - "share_history_on_invite_description": "When inviting a user to an encrypted room that has history visibility set to \"shared\", share encrypted history with that user, and accept encrypted history when you are invited to such a room.", - "share_history_on_invite_warning": "This feature is EXPERIMENTAL and not all security precautions are implemented. Do not enable on production accounts.", "sliding_sync": "Sliding Sync mode", "sliding_sync_description": "Under active development, cannot be disabled. Currently, not compatible with Element Call.", "sliding_sync_disabled_notice": "Sign in again to disable", @@ -1810,7 +1822,6 @@ "restricted": "Restricted" }, "powered_by_matrix": "Powered by Matrix", - "powered_by_matrix_with_logo": "Decentralised, encrypted chat & collaboration powered by $matrixLogo", "presence": { "away": "Away", "busy": "Busy", @@ -1842,6 +1853,12 @@ "ongoing": "Removing…", "reason_label": "Reason (optional)" }, + "remove_section_dialog": { + "confirmation": "Are you sure you want to remove this section?", + "description": "The chats in this section will still be available in your chats list.", + "remove_section": "Remove section", + "title": "Remove section?" + }, "report_content": { "description": "Reporting this message will send its unique 'event ID' to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images.", "disagree": "Disagree", @@ -3885,6 +3902,16 @@ "call_held": "%(peerName)s held the call", "call_held_resume": "You held the call Resume", "call_held_switch": "You held the call Switch", + "call_members": { + "exhaustive": { + "one": " on the call", + "other": " on the call" + }, + "overflow": { + "one": " +%(overflowCount)s on the call", + "other": " +%(overflowCount)s on the call" + } + }, "call_toast_unknown_room": "Unknown room", "camera_disabled": "Your camera is turned off", "camera_enabled": "Your camera is still enabled", @@ -3894,7 +3921,6 @@ "connection_lost": "Connectivity to the server has been lost", "connection_lost_description": "You cannot place calls without a connection to the server.", "consulting": "Consulting with %(transferTarget)s. Transfer to %(transferee)s", - "decline_call": "Decline", "default_device": "Default Device", "dial": "Dial", "dialpad": "Dialpad", @@ -3908,10 +3934,12 @@ "enable_microphone": "Unmute microphone", "expand": "Return to call", "get_call_link": "Share call link", + "group_call_started": "Group call started", "hangup": "Hangup", "hide_sidebar_button": "Hide sidebar", "input_devices": "Input devices", "jitsi_call": "Jitsi Conference", + "join_with_video": "Join with video", "legacy_call": "Legacy Call", "maximise": "Fill screen", "maximise_call": "Maximise call", @@ -3945,7 +3973,6 @@ "show_sidebar_button": "Show sidebar", "silence": "Silence call", "silenced": "Notifications silenced", - "skip_lobby_toggle_option": "Join immediately", "start_screenshare": "Start sharing your screen", "stop_screenshare": "Stop sharing your screen", "too_many_calls": "Too Many Calls", @@ -3967,7 +3994,6 @@ "user_is_presenting": "%(sharerName)s is presenting", "video_call": "Video call", "video_call_incoming": "Incoming video call", - "video_call_started": "Video call started", "video_call_using": "Video call using:", "voice_call": "Voice call", "voice_call_incoming": "Incoming voice call", @@ -3975,7 +4001,11 @@ "you_are_presenting": "You are presenting" }, "web_default_device_name": "%(appName)s: %(browserName)s on %(osName)s", - "welcome_to_element": "Welcome to Element", + "welcome": { + "tagline_element": "Supercharged for speed and simplicity.", + "title_element": "Be in your element", + "title_generic": "Welcome to %(brand)s" + }, "widget": { "added_by": "Widget added by", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/eo.json b/apps/web/src/i18n/strings/eo.json index 40f0c10f28..bddde9a546 100644 --- a/apps/web/src/i18n/strings/eo.json +++ b/apps/web/src/i18n/strings/eo.json @@ -1142,7 +1142,6 @@ "restricted": "Limigita" }, "powered_by_matrix": "Povigata de Matrix", - "powered_by_matrix_with_logo": "Malcentralizita kaj ĉifrita babilejo; kunlaboro danke al $matrixLogo", "presence": { "away": "For", "idle": "Senfara", @@ -2491,7 +2490,6 @@ "you_are_presenting": "Vi prezentas" }, "web_default_device_name": "%(appName)s: %(browserName)s sur %(osName)s", - "welcome_to_element": "Bonvenon al Element", "widget": { "added_by": "Fenestraĵon aldonis", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/es.json b/apps/web/src/i18n/strings/es.json index e45d26f78f..55a0d5c10d 100644 --- a/apps/web/src/i18n/strings/es.json +++ b/apps/web/src/i18n/strings/es.json @@ -1524,7 +1524,6 @@ "restricted": "Restringido" }, "powered_by_matrix": "Funciona con Matrix", - "powered_by_matrix_with_logo": "Conversaciones y colaboración descentralizadas y cifradas gracias a $matrixLogo", "presence": { "away": "Lejos", "busy": "Ocupado", @@ -3256,7 +3255,6 @@ "you_are_presenting": "Estás presentando" }, "web_default_device_name": "%(appName)s: %(browserName)s en %(osName)s", - "welcome_to_element": "Te damos la bienvenida a Element", "widget": { "added_by": "Accesorio añadido por", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/et.json b/apps/web/src/i18n/strings/et.json index 82e8cd0c18..3d8f43520c 100644 --- a/apps/web/src/i18n/strings/et.json +++ b/apps/web/src/i18n/strings/et.json @@ -305,7 +305,7 @@ "rate_limit_error_with_time": "Liiga palju päringuid napis ajavahemikus. Enne uuesti proovimist palun oota %(timeout)s sekundit.", "reset_successful": "Sinu salasõna on muudetud.", "return_to_login": "Mine tagasi sisselogimisvaatele", - "sign_out_other_devices": "Logi kõik oma seadmed võrgust välja" + "sign_out_other_devices": "Eemalda muud seadmed" }, "reset_password_action": "Lähtesta salasõna", "reset_password_button": "Unustasid salasõna?", @@ -692,7 +692,7 @@ "creating_rooms": "Loon jututube…", "done_action": "Palun vaata minu kogukonnakeskust", "done_action_first_room": "Mine minu esimese jututoa juurde", - "explainer": "Kogukonnad on uus võimalus jututubade ja inimeste liitmiseks. Missugust kogukonda sa tahaksid luua? Sa saad seda hiljem muuta.", + "explainer": "Kogukonnad on võimalus jututubade haldamiseks ja leidmiseks. Missugust kogukonda sa tahaksid luua?", "failed_create_initial_rooms": "Algsete jututubade loomine ei õnnestunud", "failed_invite_users": "Järgnevate kasutajate kutsumine kogukonnakeskusesse ei õnnestunud: %(csvUsers)s", "invite_teammates_by_username": "Kutsu kasutajanime alusel", @@ -703,20 +703,20 @@ "name_required": "Palun sisesta kogukonnakeskuse nimi", "personal_space": "Vaid mina", "personal_space_description": "Privaatne kogukonnakeskus jututubade koondamiseks", - "private_description": "Liitumine vaid kutse alusel, sobib sulle ja sinu lähematele kaaslastele", + "private_description": "Liitumine vaid kutse alusel, kas sulle või sinu tiimile", "private_heading": "Sinu privaatne kogukond", "private_only_heading": "Sinu kogukond", "private_personal_description": "Palun kontrolli, et vajalikel inimestel oleks ligipääs siia - %(name)s", "private_personal_heading": "Kellega sa koos töötad?", "private_space": "Mina ja minu kaasteelised", "private_space_description": "Privaatne kogukonnakeskus sinu ja sinu kaasteeliste jaoks", - "public_description": "Avaliku ligipääsuga kogukonnakeskus", + "public_description": "Kõik võivad liituda, sobib kõige paremini kogukondadele", "public_heading": "Sinu avalik kogukonnakeskus", "search_public_button": "Avalike kogukondade otsing", - "setup_rooms_community_description": "Teeme siis iga teema jaoks oma jututoa.", + "setup_rooms_community_description": "Teeme siis alustuseks mõned jututoad.", "setup_rooms_community_heading": "Mida sa sooviksid arutada %(spaceName)s kogukonnakeskuses?", "setup_rooms_description": "Sa võid ka hiljem siia luua uusi jututubasid või lisada olemasolevaid.", - "setup_rooms_private_description": "Loome siis igaühe jaoks oma jututoa.", + "setup_rooms_private_description": "Loome siis alustuseks mõned jututoad.", "setup_rooms_private_heading": "Missuguste projektidega sinu tiim tegeleb?", "share_description": "Hetkel oled siin vaid sina, aga aina paremaks läheb, kui teised liituvad.", "share_heading": "Jaga %(name)s", @@ -990,7 +990,7 @@ "set_up_recovery_toast_description": "Kui peaksid kaotama ligipääsu oma seadmetele, siis siinloodava taastevõtmega saad taastada ligipääsu oma krüptitud sõnumitele.", "set_up_toast_title": "Võta kasutusele turvaline varundus", "setup_secure_backup": { - "explainer": "Vältimaks nende kaotamist, varunda krüptovõtmed enne väljalogimist." + "explainer": "Vältimaks nende kaotamist, varunda krüptovõtmed enne seadme eemaldamist." }, "turn_on_key_storage": "Võta krüptovõtmete hoidla kasutusele", "turn_on_key_storage_description": "Salvesta oma krüptoidentiteet ja sõnumite krüptovõtmed turvaliselt serveris. See tagab, et sinu sõnumite ajalugu on alati loetav, ka kõikides uutes seadmetes.", @@ -1017,7 +1017,7 @@ "complete_description": "Sa oled edukalt verifitseerinud selle kasutaja.", "complete_title": "Verifitseeritud!", "confirm_identity_description": "Krüptitud sõnumivahetuse tagamiseks verifitseeri see seade", - "confirm_identity_title": "Kinnita, et see oled sina", + "confirm_identity_title": "Kinnita oma digitaalne identiteet", "confirm_the_emojis": "Kinnita, et kõik järgnevalt kuvatud emojid on täpselt samad, mida sa näed oma teises sessioonis.", "error_starting_description": "Meil ei õnnestunud alustada vestlust teise kasutajaga.", "error_starting_title": "Viga verifitseerimise alustamisel", @@ -1101,10 +1101,10 @@ "waiting_other_user": "Ootan kasutaja %(displayName)s verifitseerimist…" }, "verification_requested_toast_title": "Verifitseerimistaotlus on saadetud", - "verified_identity_changed": "Kasutaja %(displayName)s (%(userId)s) verifitseeritud identiteet on muutunud. Lisateave", + "verified_identity_changed": "Kasutaja %(displayName)s (%(userId)s) verifitseeritud võrguidentiteet on muutunud. Lisateave", "verified_identity_changed_no_displayname": "Kasutaja %(userId)s verifitseeritud identiteet on muutunud. Lisateave", "verify_toast_description": "Teised kasutajad ei pruugi seda usaldada", - "verify_toast_title": "Verifitseeri see sessioon", + "verify_toast_title": "Verifitseeri see seade", "withdraw_verification_action": "Eemalda verifitseerimine" }, "error": { @@ -1551,9 +1551,6 @@ "render_reaction_images_description": "Mõnikord nimetatakse neid ka „kohandatud emotikonideks“.", "report_to_moderators": "Teata moderaatoritele", "report_to_moderators_description": "Kui jututoas on modereerimine kasutusel, siis nupust „Teata sisust“ avaneva vormi abil saad jututoa reegleid rikkuvast sisust teatada moderaatoritele.", - "share_history_on_invite": "Jaga uute liikmetega krüptitud ajalugu", - "share_history_on_invite_description": "Kutsudes uut kasutajat krüptitud jututuppa, kus ajaloo nähtavus on märgitud jagatuks, siis sa jagad ajalugu selle kasutajaga ning kutsutuna nõustud selle jagamisega.", - "share_history_on_invite_warning": "See funktsionaalsus on KATSELINE ja kõik turvameetmed pole veel kasutusel. Palun ära kasuta tarbeserveris.", "sliding_sync": "Järkjärgulise sünkroniseerimise režiim", "sliding_sync_description": "Aktiivselt arendamisel ega ole võimalik välja lülitada.", "sliding_sync_disabled_notice": "Väljalülitamiseks logi Matrix'i võrgust välja ja seejärel tagasi", @@ -1798,7 +1795,6 @@ "restricted": "Piiratud õigustega kasutaja" }, "powered_by_matrix": "Põhineb Matrix'il", - "powered_by_matrix_with_logo": "Hajutatud ja krüpteeritud suhtlus- ning ühistöörakendus, mille aluseks on $matrixLogo", "presence": { "away": "Eemal", "busy": "Hõivatud", @@ -2343,7 +2339,7 @@ "join_rule_invite_description": "Liitumine toimub vaid kutse alusel.", "join_rule_knock": "Küsi võimalust liitumiseks", "join_rule_knock_description": "Kasutajade ei saa liituda enne, kui selleks vastav luba on antud.", - "join_rule_public_description": "Kõik saavad jututuba leida ja sellega liituda.", + "join_rule_public_description": "Kõik võivad jututoaga liituda.", "join_rule_restricted": "Kogukonna liikmed", "join_rule_restricted_description": "Kõik kogukonnakeskuse liikmed saavad jututuba leida ja sellega liituda. Muuda lubatud kogukonnakeskuste loendit.", "join_rule_restricted_description_active_space": "Kõik kogukonnakeskuse liikmed saavad leida ja liituda. Sa võid valida ka muid kogukonnakeskuseid.", @@ -2357,14 +2353,14 @@ "join_rule_restricted_dialog_heading_room": "Sulle teadaolevad kogukonnakeskused, millesse kuulub see jututuba", "join_rule_restricted_dialog_heading_space": "Sulle teadaolevad kogukonnakeskused, millesse kuulub see kogukond", "join_rule_restricted_dialog_heading_unknown": "Ilmselt on tegemist nendega, mille liikmed on teiste jututubade haldajad.", - "join_rule_restricted_dialog_title": "Vali kogukonnakeskused", + "join_rule_restricted_dialog_title": "Halda kogukondi", "join_rule_restricted_n_more": { "other": "ja veel %(count)s", "one": "ja veel %(count)s" }, "join_rule_restricted_summary": { - "other": "Hetkel on ligipääs %(count)s'l kogukonnakeskusel", - "one": "Hetkel sellel kogukonnal on ligipääs" + "one": "Hetkel on ühel kogukonnal ligipääs", + "other": "Hetkel on %(count)s-l kogukonnal ligipääs" }, "join_rule_restricted_upgrade_description": "Antud uuendusega on valitud kogukonnakeskuste liikmetel võimalik selle jututoaga ilma kutseta liituda.", "join_rule_restricted_upgrade_warning": "See jututuba on mõne sellise kogukonnakeskuse osa, kus sul pole haldaja õigusi. Selliselt juhul vana jututuba jätkuvalt kuvatakse, kuid selle asutajatele pakutakse võimalust uuega liituda.", @@ -2400,7 +2396,7 @@ "history_visibility_anyone_space_description": "Luba huvilistel enne liitumist näha kogukonnakeskuse eelvaadet.", "history_visibility_anyone_space_disabled": "Sul pole õigust muuta ajaloo nähtavust.", "history_visibility_anyone_space_recommendation": "Soovitame avalike kogukonnakeskuste puhul.", - "title": "Nähtavus" + "title": "Turvalisus ja privaatsus" }, "voip": { "call_type_section": "Kõne tüüp", @@ -2518,9 +2514,9 @@ "breadcrumb_page": "Lähtesta krüptimine", "breadcrumb_second_description": "Sa kaotad ligipääsu sõnumite ajalooole, mis on salvestatud vaid serveris", "breadcrumb_third_description": "Sa pead kõik oma olemasolevad seadmed ja kontaktid uuesti verifitseerima", - "breadcrumb_title": "Kas sa oled kindel, et soovid oma krüptoidentiteeti lähtestada?", + "breadcrumb_title": "Kas sa oled kindel, et soovid oma võrguidentiteeti lähtestada?", "breadcrumb_title_cant_confirm": "Sa pead oma võrguidentiteedi lähtestama", - "breadcrumb_title_forgot": "Kas unustasid oma taastevõtme? Pead oma identiteedi lähtestama.", + "breadcrumb_title_forgot": "Kas unustasid oma taastevõtme? Pead oma võrguidentiteedi lähtestama.", "breadcrumb_title_sync_failed": "Võtmehoidla sünkroniseerimine ei õnnestunud. Sa pead võrguidentiteedi lähtestama.", "breadcrumb_warning": "Tee seda ainult siis, kui arvad, et sinu kasutajakonto võib olla ohustatud kolmandate osapoolet poolt.", "details_title": "Krüptimise üksikasjad", @@ -2537,7 +2533,7 @@ "title": "Täiendav teave" }, "confirm_key_storage_off": "Kas sa oled kindel, et tahad krüptovõtmete hoidlat mitte kasutada?", - "confirm_key_storage_off_description": "Kui sa logid välja kõikidest oma seadmetest, siis kaotad ligipääsu oma sõnumite ajaloole ning pead kõik olemasolevad kontaktid uuesti verifitseerima. Lisateave", + "confirm_key_storage_off_description": "Kui sa eemaldad kõik oma seadmed, siis kaotad ligipääsu oma sõnumite ajaloole ning pead kõik olemasolevad kontaktid uuesti verifitseerima. Lisateave", "delete_key_storage": { "breadcrumb_page": "Kustuta krüptovõtmete hoidla", "confirm": "Kustuta krüptovõtmete hoidla", @@ -2569,7 +2565,7 @@ "key_storage_warning": "Sinu võtmehoidla pole sünkroonis. Vea parandamiseks palun klõpsi ühte järgnevatest nuppudest.", "save_key_description": "Ära jaga seda mitte kellegagi!", "save_key_title": "Taastevõti", - "set_up_recovery": "Seadista taastamine", + "set_up_recovery": "Seadista taastevõti", "set_up_recovery_confirm_button": "Lõpeta seadistamine", "set_up_recovery_confirm_description": "Taastamise seadistamise lõpetamiseks palun sisesta eelmises vaates näidatud taastevõti.", "set_up_recovery_confirm_title": "Kinnitamiseks sisesta oma taastevõti", @@ -2577,7 +2573,7 @@ "set_up_recovery_save_key_description": "Palun märgi see taastevõti üles ja hoia teda turvaliselt, näiteks digitaalses salasõnalaekas, krüptitud märkmetes või vana kooli seifis.", "set_up_recovery_save_key_title": "Salvesta oma taastevõti turvalisel viisil", "set_up_recovery_secondary_description": "Kui klõpsid nuppu „Jätka“, loome me sulle uue taastevõtme.", - "title": "Taastamine" + "title": "Varundus" }, "title": "Krüptimine" }, @@ -2942,8 +2938,8 @@ "other": "Kas sa oled kindel et soovid %(count)s sessiooni võrgust välja logida?" }, "sign_out_n_sessions": { - "one": "Logi %(count)s'st sessioonist välja", - "other": "Logi %(count)s'st sessioonist välja" + "one": "Eemalda %(count)s sessioon", + "other": "Eemalda %(count)s sessiooni" }, "title": "Sessioonid", "unknown_session": "Tundmatu sessioonitüüp", @@ -3129,7 +3125,7 @@ "view": "Vaata sellise aadressiga jututuba", "whois": "Näitab teavet kasutaja kohta" }, - "sliding_sync_legacy_no_longer_supported": "Järkjärgulise sünkroniseerimise (Sliding sync) varasem lahendus pole enam toetatud: uue lahenduse kasutamiseks palun logi rakendusest välja ja uuesti sisse", + "sliding_sync_legacy_no_longer_supported": "Järkjärgulise sünkroniseerimise (Sliding sync) varasem lahendus pole enam toetatud: uue lahenduse kasutamiseks palun logi rakendusesse uuesti sisse", "space": { "add_existing_room_space": { "create": "Kas sa selle asemel soovid lisada jututuba?", @@ -3950,7 +3946,6 @@ "you_are_presenting": "Sina esitad" }, "web_default_device_name": "%(appName)s: %(browserName)s operatsioonisüsteemis %(osName)s", - "welcome_to_element": "Tere tulemast kasutama suhtlusrakendust Element", "widget": { "added_by": "Vidina lisaja", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/fa.json b/apps/web/src/i18n/strings/fa.json index 14ada631b6..355487d80d 100644 --- a/apps/web/src/i18n/strings/fa.json +++ b/apps/web/src/i18n/strings/fa.json @@ -1043,7 +1043,6 @@ "restricted": "ممنوع" }, "powered_by_matrix": "راه اندازی شده با استفاده از ماتریکس", - "powered_by_matrix_with_logo": "همکاری چت غیرمتمرکز و رمزگذاری شده & توسعه یافته با استفاده از $matrixLogo", "presence": { "away": "بعدا", "idle": "بلااستفاده", @@ -2192,7 +2191,6 @@ "voice_call": "تماس صوتی" }, "web_default_device_name": "%(appName)s: %(browserName)s: روی %(osName)s", - "welcome_to_element": "به Element خوش‌آمدید", "widget": { "added_by": "ابزارک اضافه شده توسط", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/fi.json b/apps/web/src/i18n/strings/fi.json index edaa54aadf..a5988d49b9 100644 --- a/apps/web/src/i18n/strings/fi.json +++ b/apps/web/src/i18n/strings/fi.json @@ -113,7 +113,7 @@ "show_advanced": "Näytä lisäasetukset", "show_all": "Näytä kaikki", "sign_in": "Kirjaudu sisään", - "sign_out": "Kirjaudu ulos", + "sign_out": "Poista tämä laite", "skip": "Ohita", "start": "Aloita", "start_chat": "Aloita keskustelu", @@ -213,7 +213,7 @@ "incorrect_password": "Virheellinen salasana", "log_in_new_account": "Kirjaudu uudelle tilillesi.", "logout_dialog": { - "description": "Haluatko varmasti kirjautua ulos?", + "description": "Haluatko varmasti poistaa tämän laitteen?", "megolm_export": "Vie avaimet käsin", "setup_key_backup_title": "Menetät pääsyn salattuihin viesteihisi", "setup_secure_backup_description_1": "Salatut viestit turvataan päästä päähän -salauksella. Vain sinä ja viestien vastaanottaja(t) omaavat avaimet näiden viestien lukemiseen.", @@ -289,14 +289,14 @@ "registration_username_validation": "Käytä ainoastaan pieniä kirjaimia, numeroita, yhdysviivoja ja alaviivoja", "reset_password": { "confirm_new_password": "Vahvista uusi salasana", - "devices_logout_success": "Sinut on kirjattu ulos kaikilta laitteilta, etkä enää vastaanota push-ilmoituksia. Ota ilmoitukset uudelleen käyttöön kirjautumalla jokaiselle haluamallesi laitteelle.", + "devices_logout_success": "Kaikki laitteesi on poistettu, etkä enää vastaanota push-ilmoituksia. Ota ilmoitukset uudelleen käyttöön kirjautumalla jokaiselle haluamallesi laitteelle.", "password_not_entered": "Sinun täytyy syöttää uusi salasana.", "passwords_mismatch": "Uusien salasanojen on vastattava toisiaan.", "rate_limit_error": "Liikaa yrityksiä lyhyessä ajassa. Odota hetki, ennen kuin yrität uudelleen.", "rate_limit_error_with_time": "Liikaa yrityksiä lyhyessä ajassa. Yritä uudelleen, kun %(timeout)s on kulunut.", "reset_successful": "Salasanasi on nollattu.", "return_to_login": "Palaa kirjautumissivulle", - "sign_out_other_devices": "Kirjaudu ulos kaikista laitteista" + "sign_out_other_devices": "Poista muut laitteet" }, "reset_password_action": "Nollaa salasana", "reset_password_button": "Unohditko salasanan?", @@ -317,8 +317,8 @@ "server_picker_title": "Kirjaudu sisään kotipalvelimellesi", "server_picker_title_default": "Palvelinasetukset", "server_picker_title_registration": "Ylläpidä tiliä osoitteessa", - "session_logged_out_description": "Turvallisuussyistä tämä istunto on kirjattu ulos. Ole hyvä ja kirjaudu uudestaan.", - "session_logged_out_title": "Uloskirjautunut", + "session_logged_out_description": "Turvallisuussyistä tämä laite on poistettu. Ole hyvä ja kirjaudu sisään uudelleen.", + "session_logged_out_title": "Istunto poistettu", "set_email": { "description": "Tämä sallii sinun uudelleenalustaa salasanasi ja vastaanottaa ilmoituksia.", "verification_pending_description": "Ole hyvä ja tarkista sähköpostisi ja seuraa sen sisältämää linkkiä. Kun olet valmis, napsauta Jatka.", @@ -726,7 +726,7 @@ "failed_to_send": "Tapahtuman lähettäminen epäonnistui!", "invalid_json": "Ei vaikuta kelvolliselta JSON:ilta.", "level": "Taso", - "low_bandwidth_mode_description": "Vaatii yhteensopivan kotipalvelimen.", + "low_bandwidth_mode_description": "Poistaa käytöstä salauksen, läsnäolon, avatarit, lukukuittaukset ja kirjoitusilmoitukset", "main_timeline": "Pääaikajana", "no_receipt_found": "Kuittausta ei löytynyt", "number_of_users": "Käyttäjämäärä", @@ -819,11 +819,11 @@ "title": "Palautustapa poistettu", "warning": "Jos et poistanut palautustapaa, hyökkääjä saattaa yrittää käyttää tiliäsi. Vaihda tilisi salasana ja aseta uusi palautustapa asetuksissa välittömästi." }, - "set_up_recovery": "Määritä palautus", - "set_up_recovery_toast_description": "Luo palautusavain, jota voit käyttää salatun viestihistorian palauttamiseen, jos menetät pääsyn laitteisiisi.", + "set_up_recovery": "Varmuuskopioi keskustelusi", + "set_up_recovery_toast_description": "Keskustelusi varmuuskopioidaan automaattisesti päästä päähän -salauksella. Jotta voit palauttaa tämän varmuuskopion ja säilyttää digitaalisen identiteettisi, kun menetät pääsyn kaikkiin laitteisiisi, tarvitset palautusavaimesi.", "set_up_toast_title": "Määritä turvallinen varmuuskopio", "setup_secure_backup": { - "explainer": "Varmuuskopioi avaimesi ennen kuin kirjaudut ulos välttääksesi avainten menetyksen." + "explainer": "Varmuuskopioi avaimesi ennen kuin poistat tämän laitteen välttääksesi avainten menetyksen." }, "udd": { "interactive_verification_button": "Vahvista vuorovaikutteisesti emojilla", @@ -890,8 +890,8 @@ "waiting_other_user": "Odotetaan käyttäjän %(displayName)s varmennusta…" }, "verification_requested_toast_title": "Vahvistus pyydetty", - "verify_toast_description": "Muut eivät välttämättä luota siihen", - "verify_toast_title": "Vahvista tämä istunto" + "verify_toast_description": "Lokakuun 2026 lopusta lähtien vahvistamattomat laitteet eivät voi lähettää ja vastaanottaa viestejä. Lue lisää", + "verify_toast_title": "Vahvista tämä laite" }, "error": { "admin_contact": "Ota yhteyttä palvelun ylläpitäjään jatkaaksesi palvelun käyttöä.", @@ -913,15 +913,15 @@ "non_urgent_echo_failure_toast": "Palvelimesi ei vastaa joihinkin pyyntöihin.", "resource_limits": "Tämä kotipalvelin on ylittänyt yhden rajoistaan.", "session_restore": { - "clear_storage_button": "Tyhjennä varasto ja kirjaudu ulos", - "clear_storage_description": "Kirjaudu ulos ja poista salausavaimet?", - "description_1": "Törmäsimme ongelmaan yrittäessämme palauttaa edellistä istuntoasi.", + "clear_storage_button": "Poista tämä laite", + "clear_storage_description": "Poistetaanko tämä laite ja sen salausavaimet?", + "description_1": "Istuntosi palauttamisessa tapahtui virhe.", "description_2": "Jos olet aikaisemmin käyttänyt uudempaa versiota %(brand)sista, istuntosi voi olla epäyhteensopiva tämän version kanssa. Sulje tämä ikkuna ja yritä uudemman version kanssa.", - "description_3": "Selaimen varaston tyhjentäminen saattaa korjata ongelman, mutta kirjaa sinut samalla ulos ja estää sinua lukemasta salattuja keskusteluita.", + "description_3": "Selaimen tallennustilan tyhjentäminen saattaa korjata ongelman, mutta se poistaa tämän laitteen ja tekee salatusta keskusteluhistoriasta lukukelvottoman.", "title": "Istunnon palautus epäonnistui" }, "something_went_wrong": "Jokin meni vikaan!", - "storage_evicted_description_1": "Istunnon dataa, mukaan lukien salausavaimia, puuttuu. Kirjaudu ulos ja sisään, jolloin avaimet palautetaan varmuuskopiosta.", + "storage_evicted_description_1": "Jotkin istuntotiedot, mukaan lukien salattujen viestien avaimet, puuttuvat. Poista tämä laite ja kirjaudu sisään uudelleen korjataksesi tämän ja palauttaaksesi avaimet varmuuskopiosta.", "storage_evicted_description_2": "Selaimesi luultavasti poisti tämän datan, kun levytila oli vähissä.", "storage_evicted_title": "Istunnon dataa puuttuu", "sync": "Kotipalvelimeen yhdistäminen ei onnistunut. Yritetään uudelleen…", @@ -1280,7 +1280,7 @@ "report_to_moderators_description": "Moderointia tukevissa huoneissa väärinkäytökset voi ilmoittaa Ilmoita-painikkeella huoneen moderaattoreille.", "sliding_sync": "Liukuvan synkronoinnin tila", "sliding_sync_description": "Työn alla, käytöstä poistaminen ei ole mahdollista.", - "sliding_sync_disabled_notice": "Poista käytöstä kirjautumalla ulos ja takaisin sisään", + "sliding_sync_disabled_notice": "Kirjaudu uudelleen sisään poistaaksesi käytöstä", "sliding_sync_server_no_support": "Palvelimellasi ei ole tukea", "under_active_development": "Aktiivisen kehityksen kohteena.", "video_rooms": "Videohuoneet", @@ -1478,7 +1478,6 @@ "restricted": "Rajoitettu" }, "powered_by_matrix": "Moottorina Matrix", - "powered_by_matrix_with_logo": "Hajautettu, salattu keskustelu & yhteistyö, taustavoimana $matrixLogo", "presence": { "away": "Poissa", "busy": "Varattu", @@ -2117,17 +2116,17 @@ "change_recovery_confirm_title": "Anna uusi palautusavain", "change_recovery_key": "Vaihda palautusavain", "change_recovery_key_title": "Vaihdetaanko palautusavain?", - "description": "Palauta kryptografinen identiteettisi ja viestihistoriasi palautusavaimella, jos olet kadottanut kaikki olemassa olevat laitteesi.", + "description": "Keskustelusi varmuuskopioidaan automaattisesti päästä päähän -salauksella. Jotta voit palauttaa tämän varmuuskopion ja säilyttää digitaalisen identiteettisi, kun menetät pääsyn kaikkiin laitteisiisi, tarvitset palautusavaimesi.", "enter_key_error": "Kirjoittamasi palautusavain ei ole oikein.", "enter_recovery_key": "Kirjoita palautusavain", "forgot_recovery_key": "Unohditko palautusavaimen?", "save_key_description": "Älä jaa tätä kenenkään kanssa!", "save_key_title": "Palautusavain", - "set_up_recovery": "Määritä palautus", + "set_up_recovery": "Hanki palautusavain", "set_up_recovery_confirm_title": "Anna palautusavain vahvistaaksesi", "set_up_recovery_save_key_title": "Tallenna palautusavain turvalliseen paikkaan", "set_up_recovery_secondary_description": "Kun napsautat Jatka, sinulle luodaan palautusavain.", - "title": "Palautuminen" + "title": "Varmuuskopio" }, "title": "Salaus" }, @@ -2214,7 +2213,7 @@ "unable_to_load_msisdns": "Puhelinnumeroita ei voi ladata", "username": "Käyttäjätunnus" }, - "inline_url_previews_default": "Ota linkkien esikatselu käyttöön oletusarvoisesti", + "inline_url_previews_default": "Ota esikatselu käyttöön", "insert_trailing_colon_mentions": "Lisää kaksoispiste käyttäjän maininnan perään viestin alussa", "jump_to_bottom_on_send": "Siirry aikajanan pohjalle, kun lähetät viestin", "key_backup": { @@ -2371,28 +2370,28 @@ "send_read_receipts_unsupported": "Palvelimesi ei tue lukukuittausten lähettämisen poistamista käytöstä.", "send_typing_notifications": "Lähetä kirjoitusilmoituksia", "sessions": { - "best_security_note": "Parhaan turvallisuuden takaamiseksi vahvista istunnot ja kirjaudu ulos istunnoista, joita et tunnista tai et enää käytä.", + "best_security_note": "Parhaan turvallisuuden takaamiseksi vahvista istuntosi ja poista istunnot, joita et tunnista tai et enää käytä.", "browser": "Selain", "confirm_sign_out": { - "one": "Vahvista uloskirjautuminen tältä laitteelta", - "other": "Vahvista uloskirjautuminen näiltä laitteilta" + "one": "Vahvista tämän laitteen poistaminen", + "other": "Vahvista näiden laitteiden poistaminen" }, "confirm_sign_out_body": { - "one": "Napsauta alla olevaa painiketta vahvistaaksesi tämän laitteen uloskirjauksen.", - "other": "Napsauta alla olevaa painiketta vahvistaaksesi näiden laitteiden uloskirjauksen." + "one": "Napsauta alla olevaa painiketta vahvistaaksesi tämän laitteen poistamisen.", + "other": "Napsauta alla olevaa painiketta vahvistaaksesi näiden laitteiden poistamisen." }, "confirm_sign_out_continue": { - "one": "Kirjaa laite ulos", - "other": "Kirjaa laitteet ulos" + "one": "Poista laite", + "other": "Poista laitteet" }, "confirm_sign_out_sso": { - "one": "Vahvista tämän laitteen uloskirjaaminen todistamalla henkilöllisyytesi kertakirjautumista käyttäen.", - "other": "Vahvista näiden laitteiden uloskirjaaminen todistamalla henkilöllisyytesi kertakirjautumista käyttäen." + "one": "Vahvista tämän laitteen poistaminen todistamalla henkilöllisyytesi kertakirjautumista käyttäen.", + "other": "Vahvista näiden laitteiden poistaminen todistamalla henkilöllisyytesi kertakirjautumista käyttäen." }, "current_session": "Nykyinen istunto", "desktop_session": "Työpöytäistunto", "details_heading": "Istunnon tiedot", - "device_unverified_description": "Vahvista tämä istunto tai kirjaudu ulos siitä tietoturvan ja luotettavuuden parantamiseksi.", + "device_unverified_description": "Vahvista tai poista tämä istunto parhaan turvallisuuden ja luotettavuuden takaamiseksi.", "device_verified_description": "Tämä istunto on valmis turvallista viestintää varten.", "device_verified_description_current": "Nykyinen istuntosi on valmis turvalliseen viestintään.", "dialog_title": "Asetukset: Istunnot", @@ -2406,7 +2405,7 @@ "hide_details": "Piilota yksityiskohdat", "inactive_days": "Passiivinen %(inactiveAgeDays)s+ päivää", "inactive_sessions": "Passiiviset istunnot", - "inactive_sessions_list_description": "Harkitse vanhoista (%(inactiveAgeDays)s tai useamman päivän ikäisistä), käyttämättömistä istunnoista uloskirjautumista.", + "inactive_sessions_list_description": "Harkitse vanhojen (%(inactiveAgeDays)s tai useamman päivän ikäisiä), käyttämättömien istuntojen poistamista.", "ip": "IP-osoite", "last_activity": "Viimeisin toiminta", "manage": "Hallitse tätä istuntoa", @@ -2437,15 +2436,15 @@ "sign_in_with_qr_button": "Näytä QR-koodi", "sign_in_with_qr_description": "Kirjaudu QR-koodin avulla toiseen laitteeseen ja määritä suojattu viestinvälitys.", "sign_in_with_qr_unsupported": "Palveluntarjoajasi ei tue tätä", - "sign_out": "Kirjaudu ulos tästä istunnosta", - "sign_out_all_other_sessions": "Kirjaudu ulos kaikista muista istunnoista (%(otherSessionsCount)s)", + "sign_out": "Poista tämä istunto", + "sign_out_all_other_sessions": "Poista kaikki muut istunnot (%(otherSessionsCount)s )", "sign_out_confirm_description": { - "one": "Haluatko varmasti kirjautua ulos %(count)s istunnosta?", - "other": "Haluatko varmasti kirjautua ulos %(count)s istunnosta?" + "one": "Haluatko varmasti poistaa %(count)s istunnon?", + "other": "Haluatko varmasti poistaa %(count)s istuntoa?" }, "sign_out_n_sessions": { - "one": "Kirjaudu ulos %(count)s istunnosta", - "other": "Kirjaudu ulos %(count)s istunnosta" + "one": "Poista %(count)s istunto", + "other": "Poista %(count)s istuntoa" }, "title": "Istunnot", "unknown_session": "Tuntematon istunnon tyyppi", @@ -2457,7 +2456,7 @@ "url": "Verkko-osoite", "verified_session": "Vahvistettu istunto", "verified_sessions": "Vahvistetut istunnot", - "verified_sessions_list_description": "Parhaan turvallisuuden takaamiseksi kirjaudu ulos istunnoista, joita et tunnista tai et enää käytä.", + "verified_sessions_list_description": "Parhaan turvallisuuden takaamiseksi poista istunnot, joita et tunnista tai et enää käytä.", "verify_session": "Vahvista istunto", "web_session": "Web-istunto" }, @@ -2916,8 +2915,10 @@ "m.room.member": { "accepted_3pid_invite": "%(targetName)s hyväksyi kutsun %(displayName)s:tä", "accepted_invite": "%(targetName)s hyväksyi kutsun", - "ban": "%(senderName)s antoi porttikiellon käyttäjälle %(targetName)s", - "ban_reason": "%(senderName)s antoi porttikiellon käyttäjälle %(targetName)s: %(reason)s", + "ban": "%(senderName)s antoi porttikiellon", + "ban_reason": "%(senderName)s antoi porttikiellon: %(reason)s", + "ban_reason_spoiler": "%(senderName)s antoi porttikiellon käyttäjälle : %(reason)s", + "ban_spoiler": "%(senderName)s antoi porttikiellon käyttäjälle ", "change_avatar": "%(senderName)s vaihtoi profiilikuvansa", "change_name": "%(oldDisplayName)s vaihtoi näyttönimekseen %(displayName)s", "change_name_avatar": "%(oldDisplayName)s vaihtoi näyttönimensä ja profiilikuvansa", @@ -3216,7 +3217,7 @@ "ban_room_confirm_title": "Anna porttikielto huoneeseen %(roomName)s", "ban_space_everything": "Anna porttikielto kaikkeen, mihin pystyn", "deactivate_confirm_action": "Poista käyttäjä pysyvästi", - "deactivate_confirm_description": "Käyttäjän poistaminen kirjaa hänet ulos ja estää häntä kirjautumasta takaisin sisään. Lisäksi hän poistuu kaikista huoneista, joissa hän on. Tätä toimintoa ei voi kumota. Oletko varma, että haluat pysyvästi poistaa tämän käyttäjän?", + "deactivate_confirm_description": "Käyttäjän poistaminen poistaa hänen laitteensa ja estää häntä kirjautumasta takaisin sisään. Lisäksi hän poistuu kaikista huoneista, joissa hän on. Tätä toimintoa ei voi kumota. Oletko varma, että haluat pysyvästi poistaa tämän käyttäjän?", "deactivate_confirm_title": "Poista käyttäjä pysyvästi?", "demote_button": "Alenna", "demote_self_confirm_description_space": "Et voi perua tätä muutosta, koska olet alentamassa itseäsi. Jos olet viimeinen oikeutettu henkilö tässä tilassa, oikeuksia ei voi enää saada takaisin.", @@ -3369,7 +3370,6 @@ "you_are_presenting": "Esität parhaillaan" }, "web_default_device_name": "%(appName)s: %(browserName)s käyttöjärjestelmällä %(osName)s", - "welcome_to_element": "Tervetuloa Element-sovellukseen", "widget": { "added_by": "Sovelman lisäsi", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/fr.json b/apps/web/src/i18n/strings/fr.json index bef6cbf469..ea8aaa193d 100644 --- a/apps/web/src/i18n/strings/fr.json +++ b/apps/web/src/i18n/strings/fr.json @@ -681,6 +681,12 @@ "unfederated_label_default_on": "Vous devriez le déactiver si le salon est utilisé pour collaborer avec des équipes externes qui ont leur propre serveur d’accueil. Ceci ne peut pas être changé plus tard.", "unsupported_version": "Le serveur ne prend pas en charge la version de salon spécifiée." }, + "create_section_dialog": { + "create_section": "Créer une section", + "description": "Ces sections sont réservées à vous.", + "label": "Nom de la section", + "title": "Créer une section" + }, "create_space": { "add_details_prompt": "Ajoutez des informations pour aider les personnes à l’identifier.", "add_details_prompt_2": "Vous pouvez les changer à n’importe quel moment.", @@ -1563,9 +1569,6 @@ "report_to_moderators": "Signaler aux modérateurs", "report_to_moderators_description": "Dans les salons prenant en charge la modération, le bouton « Signaler » vous permet de signaler des abus aux modérateurs du salon.", "room_list_sections": "Sections de la liste de salons", - "share_history_on_invite": "Partagez l'historique chiffré avec les nouveaux membres", - "share_history_on_invite_description": "Lorsque vous invitez un utilisateur dans un salon chiffré dont la visibilité de l'historique est définie sur « partagée », partagez l'historique chiffré avec cet utilisateur et acceptez l'historique chiffré lorsque vous êtes invité dans une telle salon.", - "share_history_on_invite_warning": "Cette fonctionnalité est EXPÉRIMENTALE et toutes les précautions de sécurité ne sont pas mises en œuvre. Ne l'activez pas sur les comptes en production.", "sliding_sync": "Mode synchronisation progressive", "sliding_sync_description": "En cours de développement, ne peut être désactivé.", "sliding_sync_disabled_notice": "Connectez-vous à nouveau pour désactiver", @@ -1810,7 +1813,6 @@ "restricted": "Restreint" }, "powered_by_matrix": "Propulsé par Matrix", - "powered_by_matrix_with_logo": "Messagerie décentralisée, chiffrée & une collaboration alimentée par $matrixLogo", "presence": { "away": "Absent", "busy": "Occupé", @@ -3974,7 +3976,6 @@ "you_are_presenting": "Vous êtes à l’écran" }, "web_default_device_name": "%(appName)s : %(browserName)s pour %(osName)s", - "welcome_to_element": "Bienvenue sur Element", "widget": { "added_by": "Widget ajouté par", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/gl.json b/apps/web/src/i18n/strings/gl.json index 4c57a53c69..3aa7de88bc 100644 --- a/apps/web/src/i18n/strings/gl.json +++ b/apps/web/src/i18n/strings/gl.json @@ -1314,7 +1314,6 @@ "restricted": "Restrinxido" }, "powered_by_matrix": "Funciona grazas a Matrix", - "powered_by_matrix_with_logo": "Conversas & colaboración descentralizadas e cifradas grazas a $matrixLogo", "presence": { "away": "Fóra", "busy": "Ocupado", @@ -2908,7 +2907,6 @@ "you_are_presenting": "Estaste a presentar" }, "web_default_device_name": "%(appName)s: %(browserName)s en %(osName)s", - "welcome_to_element": "Benvida/o a Element", "widget": { "added_by": "Widget engadido por", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/he.json b/apps/web/src/i18n/strings/he.json index a25d699131..9ed00733bb 100644 --- a/apps/web/src/i18n/strings/he.json +++ b/apps/web/src/i18n/strings/he.json @@ -1108,7 +1108,6 @@ "restricted": "מחוץ לתחום" }, "powered_by_matrix": "מופעל על ידי מטריקס", - "powered_by_matrix_with_logo": "צ'אט מבוזר ומוצפן & מופעל בשיתוף פעולה ע\"י $matrixLogo", "presence": { "away": "מרוחק", "idle": "לא פעיל", @@ -2374,7 +2373,6 @@ "you_are_presenting": "אתה מציג" }, "web_default_device_name": "%(appName)s: %(browserName)s עַל %(osName)s", - "welcome_to_element": "ברוכים הבאים ל Element", "widget": { "added_by": "ישומון נוסף על ידי", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/hr.json b/apps/web/src/i18n/strings/hr.json index 01a38d5c33..0c7c1a3cfd 100644 --- a/apps/web/src/i18n/strings/hr.json +++ b/apps/web/src/i18n/strings/hr.json @@ -1564,9 +1564,6 @@ "render_reaction_images_description": "Ponekad se nazivaju „prilagođeni emotikoni”", "report_to_moderators": "Prijavi moderatorima", "report_to_moderators_description": "U sobama koje podržavaju moderiranje gumb „Prijavi” omogućit će vam da moderatorima sobe prijavite svako neprimjereno ponašanje.", - "share_history_on_invite": "Podijeli šifriranu povijest s novim članovima", - "share_history_on_invite_description": "Prilikom pozivanja korisnika u šifriranu sobu čija je vidljivost povijesti postavljena na podijeljenu, podijelite šifriranu povijest s tim korisnikom i prihvatite šifriranu povijest kada budete pozvani u takvu sobu.", - "share_history_on_invite_warning": "Ova je značajka u EKSPERIMENTALNOJ FAZI i nisu implementirane sve sigurnosne mjere. Ne omogućavajte je na produkcijskim računima.", "sliding_sync": "Način rada Sliding Sync", "sliding_sync_description": "Značajka je u razvoju, ne može se onemogućiti. Trenutačno nije kompatibilna s Element Callom.", "sliding_sync_disabled_notice": "Odjavite se i ponovno prijavite da biste onemogućili", @@ -1816,7 +1813,6 @@ "restricted": "Ograničeno" }, "powered_by_matrix": "Pokreće Matrix", - "powered_by_matrix_with_logo": "Decentralizirani, šifrirani razgovori i suradnja koje pokreće $matrixLogo", "presence": { "away": "Odsutan/na", "busy": "Zauzeto", @@ -4037,7 +4033,6 @@ "you_are_presenting": "Dijelite zaslon" }, "web_default_device_name": "%(appName)s: %(browserName)s na %(osName)s", - "welcome_to_element": "Dobro došli u Element", "widget": { "added_by": "Widget je dodao/la", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/hu.json b/apps/web/src/i18n/strings/hu.json index 78e9cf2222..c57bb688e5 100644 --- a/apps/web/src/i18n/strings/hu.json +++ b/apps/web/src/i18n/strings/hu.json @@ -1555,9 +1555,6 @@ "report_to_moderators": "Jelentés a moderátoroknak", "report_to_moderators_description": "A moderálást támogató szobákban a problémás tartalmat a „Jelentés” gombbal lehet a moderátorok felé jelezni.", "room_list_sections": "Szobalista szakaszok", - "share_history_on_invite": "Titkosított előzmények megosztása az új tagokkal", - "share_history_on_invite_description": "Ha egy felhasználót olyan titkosított szobába hív meg, amelynél az előzmények láthatósága „megosztott” beállításra van állítva, akkor a titkosított előzményeket megosztja azzal a felhasználóval, valamint elfogadja a titkosított előzményeket, ha ilyen szobába hívják meg.", - "share_history_on_invite_warning": "Ez a funkció KÍSÉRLETI JELLEGŰ, és nem minden biztonsági óvintézkedés van megvalósítva. Ne engedélyezze éles fiókokban.", "sliding_sync": "Csúszóablakos szinkronizálási mód", "sliding_sync_description": "Aktív fejlesztés alatt, nem kapcsolható ki.", "sliding_sync_disabled_notice": "Jelentkezzen be újra a letiltáshoz", @@ -1799,7 +1796,6 @@ "restricted": "Korlátozott" }, "powered_by_matrix": "A gépházban: Matrix", - "powered_by_matrix_with_logo": "Elosztott, titkosított csevegés és együttműködés ezzel: $matrixLogo", "presence": { "away": "Távol", "busy": "Foglalt", @@ -3944,7 +3940,6 @@ "you_are_presenting": "Ön tartja a bemutatót" }, "web_default_device_name": "%(appName)s: (%(browserName)s itt: %(osName)s)", - "welcome_to_element": "Üdvözli az Element", "widget": { "added_by": "A kisalkalmazást hozzáadta", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/hy.json b/apps/web/src/i18n/strings/hy.json index c75bef0ccb..69599a6d2e 100644 --- a/apps/web/src/i18n/strings/hy.json +++ b/apps/web/src/i18n/strings/hy.json @@ -1493,9 +1493,6 @@ "render_reaction_images_description": "Երբեմն կոչվում է “հատուկ էմոջիներ”:", "report_to_moderators": "Հաղորդել մոդերատորներին", "report_to_moderators_description": "Սենյակներում, որոնք աջակցում են չափավորումը, “Հաշվետվություն” կոճակը թույլ կտա ձեզ հաղորդել չարաշահումների մասին սենյակների մոդերատորներին:", - "share_history_on_invite": "Կիսվել գաղտնսգրված պատմությամբ նոր անդամների հետ", - "share_history_on_invite_description": "Եթե օգտատիրոջը հրավիրում եք գաղտնագրված սենյակ, որի պատմության հասանելիությունը «կիսված» է, ապա նրա հետ կիսվում է գաղտնագրված պատմությունը, և դուք նույնպես ստանում եք գաղտնագրված պատմություն, երբ հրավիրվում եք նման սենյակ։", - "share_history_on_invite_warning": "Այս գործառույթը ՓՈՐՁԱՐԱՐԱԿԱՆ է, և ոչ բոլոր անվտանգության միջոցառումներն են ներդրված։ Մի՛ միացրեք այն իրական(կարևոր) հաշիվներում։", "sliding_sync": "Սահող համաժամեցման(Sliding Sync) ռեժիմ", "sliding_sync_description": "Ակտիվ մշակման փուլում է, չի կարող անջատվել։", "sliding_sync_disabled_notice": "Անջատելու համար դուրս եկեք և նորից մուտք գործեք", @@ -1729,7 +1726,6 @@ "restricted": "Սահմանափակված" }, "powered_by_matrix": "Մշակված Matrix-ի կողմից", - "powered_by_matrix_with_logo": "Ապակենտրոնացված և գաղտնագրված զրույց ու համագործակցություն՝ $matrixLogo-ի աջակցությամբ", "presence": { "away": "Տեղում չէ", "busy": "Զբաղված", @@ -3849,7 +3845,6 @@ "you_are_presenting": "Դուք ներկայացնում եք" }, "web_default_device_name": "%(appName)s: %(browserName)s %(osName)s-ի վրա", - "welcome_to_element": "Բարի գալուստ Element", "widget": { "added_by": "Վիջեթը ավելացվել է", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/id.json b/apps/web/src/i18n/strings/id.json index f38c311c4c..6868a9c8cd 100644 --- a/apps/web/src/i18n/strings/id.json +++ b/apps/web/src/i18n/strings/id.json @@ -1550,9 +1550,6 @@ "render_reaction_images_description": "Terkadang disebut sebagai \"emoji khusus\".", "report_to_moderators": "Laporkan ke moderator", "report_to_moderators_description": "Dalam ruangan yang mendukung moderasi, tombol “Laporkan” memungkinkan Anda untuk melaporkan penyalahgunaan ke moderator ruangan.", - "share_history_on_invite": "Bagikan riwayat terenkripsi dengan anggota baru", - "share_history_on_invite_description": "Saat mengundang pengguna ke ruangan terenkripsi yang memiliki keterlihatan riwayat yang diatur ke \"terbagi\", bagikan riwayat terenkripsi dengan pengguna tersebut, dan terima riwayat terenkripsi saat Anda diundang ke ruangan tersebut.", - "share_history_on_invite_warning": "Fitur ini bersifat EKSPERIMENTAL dan tidak semua tindakan pencegahan keamanan diterapkan. Jangan aktifkan pada akun produksi.", "sliding_sync": "Mode Sinkronisasi Geser", "sliding_sync_description": "Dalam pengembangan aktif, tidak dapat dinonaktifkan.", "sliding_sync_disabled_notice": "Keluar dan masuk kembali ke akun untuk menonaktifkan", @@ -1794,7 +1791,6 @@ "restricted": "Dibatasi" }, "powered_by_matrix": "Diberdayakan oleh Matrix", - "powered_by_matrix_with_logo": "Obrolan & kolaborasi terdesentralisasi dan terenkripsi diberdayakan oleh $matrixLogo", "presence": { "away": "Idle", "busy": "Sibuk", @@ -3937,7 +3933,6 @@ "you_are_presenting": "Anda sedang mempresentasi" }, "web_default_device_name": "%(appName)s: %(browserName)s di %(osName)s", - "welcome_to_element": "Selamat datang di Element", "widget": { "added_by": "Widget ditambahkan oleh", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/is.json b/apps/web/src/i18n/strings/is.json index 9eba2eb287..b7fad1bd7a 100644 --- a/apps/web/src/i18n/strings/is.json +++ b/apps/web/src/i18n/strings/is.json @@ -1274,7 +1274,6 @@ "restricted": "Takmarkað" }, "powered_by_matrix": "Keyrt með Matrix", - "powered_by_matrix_with_logo": "Dreifstýrt, dulritað spjall og samskipti keyrt með $matrixLogo", "presence": { "away": "Fjarverandi", "busy": "Upptekinn", @@ -2830,7 +2829,6 @@ "you_are_presenting": "Þú ert að kynna" }, "web_default_device_name": "%(appName)s: %(browserName)s á %(osName)s", - "welcome_to_element": "Velkomin í Element", "widget": { "added_by": "Viðmótshluta bætt við af", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/it.json b/apps/web/src/i18n/strings/it.json index 8189af9104..07296b29f9 100644 --- a/apps/web/src/i18n/strings/it.json +++ b/apps/web/src/i18n/strings/it.json @@ -1538,7 +1538,6 @@ "restricted": "Limitato" }, "powered_by_matrix": "Offerto da Matrix", - "powered_by_matrix_with_logo": "Chat e collaborazioni criptate e decentralizzate offerte da $matrixLogo", "presence": { "away": "Assente", "busy": "Occupato", @@ -3419,7 +3418,6 @@ "you_are_presenting": "Stai presentando" }, "web_default_device_name": "%(appName)s: %(browserName)s su %(osName)s", - "welcome_to_element": "Benvenuti su Element", "widget": { "added_by": "Widget aggiunto da", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/ja.json b/apps/web/src/i18n/strings/ja.json index 490416472f..74abafc872 100644 --- a/apps/web/src/i18n/strings/ja.json +++ b/apps/web/src/i18n/strings/ja.json @@ -1431,7 +1431,6 @@ "moderator": "モデレーター", "restricted": "制限" }, - "powered_by_matrix_with_logo": "$matrixLogo による、分散型で暗号化された会話とコラボレーション", "presence": { "away": "離席中", "busy": "取り込み中", @@ -3142,7 +3141,6 @@ "you_are_presenting": "あなたが画面を共有しています" }, "web_default_device_name": "%(appName)s: %(osName)sの%(browserName)s", - "welcome_to_element": "Elementにようこそ", "widget": { "added_by": "ウィジェットの追加者", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/ko.json b/apps/web/src/i18n/strings/ko.json index 474b3f567f..fcbf1776ef 100644 --- a/apps/web/src/i18n/strings/ko.json +++ b/apps/web/src/i18n/strings/ko.json @@ -1549,9 +1549,6 @@ "report_to_moderators": "관리자에게 신고하기", "report_to_moderators_description": "운영자가 관리하는 방에서는 '신고' 버튼으로 관리자에게 문제를 신고할 수 있습니다.", "room_list_sections": "대화방 목록 섹션", - "share_history_on_invite": "새 멤버에게 암호화 된 기록을 공유합니다.", - "share_history_on_invite_description": "기록 공개 설정이 '공유'로 설정된 암호화된 방으로 사용자를 초대할 때, 해당 사용자와 암호화된 기록을 공유하고, 그러한 방에 초대받았을 때는 암호화된 기록을 수락하세요.", - "share_history_on_invite_warning": "이 기능은 실험용 기능으로, 모든 보안 조치가 구현되어 있지는 않습니다. 실제 운영 계정에서는 활성화하지 마십시오.", "sliding_sync": "슬라이딩 동기화 모드", "sliding_sync_description": "개발 진행 중인 기능이며 비활성화는 불가능합니다. 현재 Element Call 기능은 지원하지 않습니다.", "sliding_sync_disabled_notice": "기능을 해제하려면 다시 로그인하세요", @@ -1782,7 +1779,6 @@ "restricted": "제한됨" }, "powered_by_matrix": "Powered by Matrix", - "powered_by_matrix_with_logo": "분산형, 암호화된 채팅 및 협업 플랫폼 $matrixLogo", "presence": { "away": "자리 비움", "busy": "다른 용무 중", @@ -3868,7 +3864,6 @@ "you_are_presenting": "화면을 공유 중입니다." }, "web_default_device_name": "%(appName)s: %(browserName)s on %(osName)s", - "welcome_to_element": "Element에 오신 것을 환영합니다", "widget": { "added_by": "위젯을 추가했습니다", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/lo.json b/apps/web/src/i18n/strings/lo.json index 4ae16559a0..e73c3c5841 100644 --- a/apps/web/src/i18n/strings/lo.json +++ b/apps/web/src/i18n/strings/lo.json @@ -1281,7 +1281,6 @@ "restricted": "ຖືກຈຳກັດ" }, "powered_by_matrix": "ສະໜັບສະໜູນໂດຍ Matrix", - "powered_by_matrix_with_logo": "ການສົນທະນາແບບເຂົ້າລະຫັດ ແລະກະຈ່າຍການຄຸ້ມຄອງ & ການຮ່ວມມື້ ແລະສະໜັບສະໜູນໂດຍ $matrixLogo", "presence": { "away": "ຫ່າງອອກໄປ", "busy": "ບໍ່ຫວ່າງ", @@ -2812,7 +2811,6 @@ "voice_call": "ໂທດ້ວຍສຽງ", "you_are_presenting": "ທ່ານກໍາລັງນໍາສະເຫນີ" }, - "welcome_to_element": "ຍິນດີຕ້ອນຮັບ", "widget": { "added_by": "ເພີ່ມWidgetໂດຍ", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/lt.json b/apps/web/src/i18n/strings/lt.json index 9b90d0b829..2ff4a8ad86 100644 --- a/apps/web/src/i18n/strings/lt.json +++ b/apps/web/src/i18n/strings/lt.json @@ -950,7 +950,6 @@ "restricted": "Apribotas" }, "powered_by_matrix": "Veikia su Matrix", - "powered_by_matrix_with_logo": "Decentralizuotas, užšifruotų pokalbių & bendradarbiavimas, paremtas $matrixLogo", "presence": { "busy": "Užsiėmęs", "idle": "Neveiklus", @@ -2278,7 +2277,6 @@ "voice_call": "Balso skambutis", "you_are_presenting": "Jūs pristatote" }, - "welcome_to_element": "Sveiki atvykę į Element", "widget": { "added_by": "Valdiklį pridėjo", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/nb_NO.json b/apps/web/src/i18n/strings/nb_NO.json index 70c828465e..78bee47967 100644 --- a/apps/web/src/i18n/strings/nb_NO.json +++ b/apps/web/src/i18n/strings/nb_NO.json @@ -1551,9 +1551,6 @@ "render_reaction_images_description": "Noen ganger omtalt som \"egendefinerte emojier\".", "report_to_moderators": "Rapporter til moderatorene", "report_to_moderators_description": "I rom som støtter moderering, kan du bruke \"Rapporter\"-knappen for å rapportere misbruk til rommoderatorene.", - "share_history_on_invite": "Del kryptert historikk med nye medlemmer", - "share_history_on_invite_description": "Når du inviterer en bruker til et kryptert rom som har historikkvisibilitet satt til «delt», deler du kryptert historikk med den brukeren, og aksepterer kryptert historikk når du blir invitert til et slikt rom.", - "share_history_on_invite_warning": "Denne funksjonen er EKSPERIMENTELL, og ikke alle sikkerhetstiltak er implementert. Ikke aktiver på produksjonskontoer.", "sliding_sync": "Sliding Sync modus", "sliding_sync_description": "Under aktiv utvikling, kan ikke deaktiveres.", "sliding_sync_disabled_notice": "Logg ut og inn igjen for å deaktivere", @@ -1798,7 +1795,6 @@ "restricted": "Begrenset" }, "powered_by_matrix": "Drevet av Matrix", - "powered_by_matrix_with_logo": "Desentralisert, kryptert chat og samarbeid drevet av $matrixLogo", "presence": { "away": "Borte", "busy": "Opptatt", @@ -3944,7 +3940,6 @@ "you_are_presenting": "Du presenterer" }, "web_default_device_name": "%(appName)s: %(browserName)s på %(osName)s", - "welcome_to_element": "Velkommen til Element", "widget": { "added_by": "Modulen ble lagt til av", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/nl.json b/apps/web/src/i18n/strings/nl.json index 39df2fb253..7d3426c61f 100644 --- a/apps/web/src/i18n/strings/nl.json +++ b/apps/web/src/i18n/strings/nl.json @@ -1324,7 +1324,6 @@ "restricted": "Beperkte toegang" }, "powered_by_matrix": "Mogelijk gemaakt door Matrix", - "powered_by_matrix_with_logo": "Gedecentraliseerde, versleutelde chat & samenwerking mogelijk gemaakt door $matrixLogo", "presence": { "away": "Afwezig", "busy": "Bezet", @@ -2966,7 +2965,6 @@ "you_are_presenting": "Je bent aan het presenteren" }, "web_default_device_name": "%(appName)s: %(browserName)s op %(osName)s", - "welcome_to_element": "Welkom bij Element", "widget": { "added_by": "Widget toegevoegd door", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/pl.json b/apps/web/src/i18n/strings/pl.json index 53a1edb041..4a5428e5eb 100644 --- a/apps/web/src/i18n/strings/pl.json +++ b/apps/web/src/i18n/strings/pl.json @@ -1526,9 +1526,6 @@ "render_reaction_images_description": "Czasami określane jako \"emoji niestandardowe\".", "report_to_moderators": "Zgłoś do moderatorów", "report_to_moderators_description": "W pokojach, które wspierają moderacje, przycisk \"Zgłoś\" pozwoli Ci zgłosić nadużycia moderatorom.", - "share_history_on_invite": "Udostępnij zaszyfrowaną historię nowym członkom", - "share_history_on_invite_description": "Gdy zapraszasz użytkownika do zaszyfrowanego pokoju, w którym widoczność historii jest ustawiona na „udostępniona”, udostępnij historię wiadomości temu użytkownikowi lub zaakceptuj ją, gdy dołączysz do takiego pokoju.", - "share_history_on_invite_warning": "Ta funkcja jest EKSPERYMENTALNA i nie wszystkie środki bezpieczeństwa są w niej zaimplementowane. Nie włączaj na kontach produkcyjnych.", "sliding_sync": "Tryb synchronizacji przesuwanej", "sliding_sync_description": "W trakcie aktywnego rozwoju, nie można wyłączyć.", "sliding_sync_disabled_notice": "Zaloguj się ponownie, aby wyłączyć", @@ -1763,7 +1760,6 @@ "restricted": "Ograniczony" }, "powered_by_matrix": "Zasilane przez Matrix", - "powered_by_matrix_with_logo": "Zdecentralizowany czat szyfrowany i współpraca oparta na $matrixLogo", "presence": { "away": "Z dala od urządzenia", "busy": "Zajęty", @@ -3912,7 +3908,6 @@ "you_are_presenting": "Prezentujesz" }, "web_default_device_name": "%(appName)s: %(browserName)s na %(osName)s", - "welcome_to_element": "Witamy w Element", "widget": { "added_by": "Widżet dodany przez", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/pt.json b/apps/web/src/i18n/strings/pt.json index 64957224e6..cbe8504bd2 100644 --- a/apps/web/src/i18n/strings/pt.json +++ b/apps/web/src/i18n/strings/pt.json @@ -1676,7 +1676,6 @@ "restricted": "Restrito" }, "powered_by_matrix": "Fornecido por Matrix", - "powered_by_matrix_with_logo": "Conversa e colaboração descentralizadas e encriptadas com a tecnologia $matrixLogo", "presence": { "away": "Ausente", "busy": "Ocupado", @@ -3757,7 +3756,6 @@ "you_are_presenting": "Estás a apresentar" }, "web_default_device_name": "%(appName)s: %(browserName)s em %(osName)s", - "welcome_to_element": "Bem-vindo ao Element", "widget": { "added_by": "Widget adicionado por", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/pt_BR.json b/apps/web/src/i18n/strings/pt_BR.json index 6941da75d6..c89dadaa45 100644 --- a/apps/web/src/i18n/strings/pt_BR.json +++ b/apps/web/src/i18n/strings/pt_BR.json @@ -1542,9 +1542,6 @@ "render_reaction_images_description": "Às vezes chamados de “emojis personalizados”.", "report_to_moderators": "Reportar aos moderadores", "report_to_moderators_description": "Em salas que aceitam moderação, o botão \"Denunciar\" permitirá que você denuncie abusos aos moderadores da sala.", - "share_history_on_invite": "Compartilhe o histórico criptografado com novos membros", - "share_history_on_invite_description": "Quando você convidar alguém para uma sala criptografada com o histórico visível para todos, compartilhe o histórico criptografado com essa pessoa e aceite o histórico criptografado quando for convidado para uma sala assim.", - "share_history_on_invite_warning": "Este recurso é EXPERIMENTAL e nem todas as precauções de segurança estão implementadas. Não o habilite em contas de produção.", "sliding_sync": "Modo Sliding Sync", "sliding_sync_description": "Em desenvolvimento ativo, não pode ser desativado.", "sliding_sync_disabled_notice": "Saia e entre novamente para desativar", @@ -1778,7 +1775,6 @@ "restricted": "Restrito" }, "powered_by_matrix": "Desenvolvido por Matrix", - "powered_by_matrix_with_logo": "Chat descentralizado e encriptado & colaboração, powered by $matrixLogo", "presence": { "away": "Ausente", "busy": "Ocupado", @@ -3918,7 +3914,6 @@ "you_are_presenting": "Você está apresentando" }, "web_default_device_name": "%(appName)s: %(browserName)s em %(osName)s", - "welcome_to_element": "Boas-vindas a Element", "widget": { "added_by": "Widget adicionado por", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/ru.json b/apps/web/src/i18n/strings/ru.json index 0cc7277f64..1bbf081882 100644 --- a/apps/web/src/i18n/strings/ru.json +++ b/apps/web/src/i18n/strings/ru.json @@ -1552,9 +1552,6 @@ "report_to_moderators": "Пожаловаться модераторам", "report_to_moderators_description": "В поддерживающих модерирование чатах, кнопка \"Пожаловаться\" позволит вам сообщить о нарушении модераторам комнаты.", "room_list_sections": "Разделы списка чатов", - "share_history_on_invite": "Поделиться зашифрованной историей с новыми участниками", - "share_history_on_invite_description": "Приглашая пользователя в зашифрованную комнату, для которой установлена видимость истории как «общая», поделитесь зашифрованной историей с этим пользователем и примите зашифрованную историю, когда вас приглашают в такую комнату.", - "share_history_on_invite_warning": "Эта функция ЭКСПЕРИМЕНТАЛЬНАЯ и в ней реализованы не все меры безопасности. Не включайте её в рабочих учётных записях.", "sliding_sync": "Режим Sliding Sync", "sliding_sync_description": "В активной разработке, нельзя отключить.", "sliding_sync_disabled_notice": "Выйдите из системы и снова войдите, чтобы отключить", @@ -1797,7 +1794,6 @@ "restricted": "Ограниченный пользователь" }, "powered_by_matrix": "На технологии Matrix", - "powered_by_matrix_with_logo": "Децентрализованное, зашифрованное общение и сотрудничество на основе $matrixLogo", "presence": { "away": "Отошёл(ла)", "busy": "Занят(а)", @@ -3985,7 +3981,6 @@ "you_are_presenting": "Вы представляете" }, "web_default_device_name": "%(appName)s: %(browserName)s на %(osName)s", - "welcome_to_element": "Добро пожаловать в Element", "widget": { "added_by": "Виджет добавлен", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/sk.json b/apps/web/src/i18n/strings/sk.json index 3ba4c4db1e..667307f088 100644 --- a/apps/web/src/i18n/strings/sk.json +++ b/apps/web/src/i18n/strings/sk.json @@ -1565,9 +1565,6 @@ "render_reaction_images_description": "Niekedy sa označujú ako „vlastné emotikony“.", "report_to_moderators": "Nahlásiť moderátorom", "report_to_moderators_description": "V miestnostiach, ktoré podporujú moderovanie, môžete pomocou tlačidla \"Nahlásiť\" nahlásiť porušovanie pravidiel moderátorom miestnosti.", - "share_history_on_invite": "Zdieľajte zašifrovanú históriu s novými členmi", - "share_history_on_invite_description": "Pri pozývaní používateľa do zašifrovanej miestnosti s históriou nastavenou na „zdieľanú“ zdieľať s týmto používateľom aj zašifrovanú históriu a pri pozvaní do takejto miestnosti prijať zašifrovanú históriu.", - "share_history_on_invite_warning": "Táto funkcia je EXPERIMENTÁLNA a nie sú implementované všetky bezpečnostné opatrenia. Nepovoľujte ju v produkčných účtoch.", "sliding_sync": "Režim kĺzavej synchronizácie", "sliding_sync_description": "V štádiu aktívneho vývoja, nie je možné to vypnúť.", "sliding_sync_disabled_notice": "Odhláste sa a znova sa prihláste, aby sa to vyplo", @@ -1817,7 +1814,6 @@ "restricted": "Obmedzené" }, "powered_by_matrix": "používa protokol Matrix", - "powered_by_matrix_with_logo": "Decentralizované, šifrované konverzácie a spolupráca na platforme $matrixLogo", "presence": { "away": "Preč", "busy": "Obsadený/zaneprázdnený", @@ -4024,7 +4020,6 @@ "you_are_presenting": "Prezentujete" }, "web_default_device_name": "%(appName)s: %(browserName)s na %(osName)s", - "welcome_to_element": "Víta vás Element", "widget": { "added_by": "Widget pridaný používateľom", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/sq.json b/apps/web/src/i18n/strings/sq.json index cef1c7e45e..db8ea4f362 100644 --- a/apps/web/src/i18n/strings/sq.json +++ b/apps/web/src/i18n/strings/sq.json @@ -1442,7 +1442,6 @@ "restricted": "E kufizuar" }, "powered_by_matrix": "Bazuar në Matrix", - "powered_by_matrix_with_logo": "Fjalosje & bashkëpunim i decentralizuar, i fshehtëzuar, bazuar në $matrixLogo", "presence": { "away": "I larguar", "busy": "I zënë", @@ -3202,7 +3201,6 @@ "you_are_presenting": "Përfaqësoni" }, "web_default_device_name": "%(appName)s: %(browserName)s në %(osName)s", - "welcome_to_element": "Mirë se vini te Element", "widget": { "added_by": "Widget i shtuar nga", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/sv.json b/apps/web/src/i18n/strings/sv.json index 48715ab1ae..52aa32e1e6 100644 --- a/apps/web/src/i18n/strings/sv.json +++ b/apps/web/src/i18n/strings/sv.json @@ -1492,9 +1492,6 @@ "render_reaction_images_description": "Ibland kallat för ”anpassade emojis”.", "report_to_moderators": "Rapportera till moderatorer", "report_to_moderators_description": "I rum som stöder moderering så låter \"Rapportera\"-knappen dig rapportera trakasseri till rumsmoderatorer.", - "share_history_on_invite": "Dela encrypted historik med nya medlemmar", - "share_history_on_invite_description": "När du bjuder in en användare till ett krypterat rum där historiksynlighet är inställd på \"delad\" så delar du krypterad historik med den användaren och du accepterar krypterad historik när du blir inbjuden till ett sådant rum.", - "share_history_on_invite_warning": "Den här funktionen är EXPERIMENTELL och alla säkerhetsåtgärder är inte implementerade. Aktivera inte på produktionskonton.", "sliding_sync": "Sliding sync-läge", "sliding_sync_description": "Under aktiv utveckling, kan inte inaktiveras.", "sliding_sync_disabled_notice": "Logga ut och in igen för att inaktivera", @@ -1728,7 +1725,6 @@ "restricted": "Begränsad" }, "powered_by_matrix": "Drivs av Matrix", - "powered_by_matrix_with_logo": "Decentraliserad krypterad chatt & samarbete som drivs av $matrixLogo", "presence": { "away": "Borta", "busy": "Upptagen", @@ -3846,7 +3842,6 @@ "you_are_presenting": "Du presenterar" }, "web_default_device_name": "%(appName)s: %(browserName)s på %(osName)s", - "welcome_to_element": "Välkommen till Element", "widget": { "added_by": "Widget tillagd av", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/tr.json b/apps/web/src/i18n/strings/tr.json index 142d45f61f..f98c5870f4 100644 --- a/apps/web/src/i18n/strings/tr.json +++ b/apps/web/src/i18n/strings/tr.json @@ -1675,7 +1675,6 @@ "restricted": "Sınırlı" }, "powered_by_matrix": "Matrix tarafından desteklenmektedir", - "powered_by_matrix_with_logo": "Merkezi olmayan, şifreli sohbet ve işbirliği $matrixLogo tarafından desteklenmektedir.", "presence": { "away": "Uzakta", "busy": "Meşgul", @@ -3743,7 +3742,6 @@ "you_are_presenting": "Sunum yapıyorsunuz" }, "web_default_device_name": "%(appName)s: %(browserName)s %(osName)s", - "welcome_to_element": "Element'e Hoş Geldiniz", "widget": { "added_by": "Widget ekleyen", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/uk.json b/apps/web/src/i18n/strings/uk.json index d1a9e78af1..5b64446e6d 100644 --- a/apps/web/src/i18n/strings/uk.json +++ b/apps/web/src/i18n/strings/uk.json @@ -1546,9 +1546,6 @@ "render_reaction_images_description": "Іноді їх називають \"користувацькими емодзі\".", "report_to_moderators": "Поскаржитись модераторам", "report_to_moderators_description": "У кімнатах, які підтримують модерацію, кнопка «Поскаржитися» дає змогу повідомити про зловживання модераторам кімнати.", - "share_history_on_invite": "Ділитися зашифрованою історією з новими учасниками", - "share_history_on_invite_description": "Коли запрошуєте користувача до зашифрованої кімнати, в якій видимість історії встановлено як «спільна», поділіться зашифрованою історією з цим користувачем і прийміть зашифровану історію, коли вас запросять до такої кімнати.", - "share_history_on_invite_warning": "Ця функція ЕКСПЕРИМЕНТАЛЬНА, і впроваджено не всі заходи безпеки. Не вмикайте її на робочих облікових записах.", "sliding_sync": "Режим ковзної синхронізації", "sliding_sync_description": "На стадії активної розробки, вимкнути не можна.", "sliding_sync_disabled_notice": "Вийдіть і знову увійдіть, щоб вимкнути", @@ -1791,7 +1788,6 @@ "restricted": "Обмежено" }, "powered_by_matrix": "Працює на Matrix", - "powered_by_matrix_with_logo": "Децентралізована, зашифрована бесіда та співпраця на основі $matrixLogo", "presence": { "away": "Не на зв'язку", "busy": "Зайнятий", @@ -3956,7 +3952,6 @@ "you_are_presenting": "Ви показуєте" }, "web_default_device_name": "%(appName)s: %(browserName)s на %(osName)s", - "welcome_to_element": "Ласкаво просимо до Element", "widget": { "added_by": "Вджет додано", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/vi.json b/apps/web/src/i18n/strings/vi.json index 109d09a3aa..141845f695 100644 --- a/apps/web/src/i18n/strings/vi.json +++ b/apps/web/src/i18n/strings/vi.json @@ -1411,7 +1411,6 @@ "restricted": "Bị hạn chế" }, "powered_by_matrix": "Chạy trên giao thức Matrix", - "powered_by_matrix_with_logo": "Dịch vụ nhắn tin & liên lạc được mã hóa, phi tập trung. Được vận hành trên $matrixLogo", "presence": { "away": "Vắng mặt", "busy": "Bận", @@ -3143,7 +3142,6 @@ "you_are_presenting": "Bạn đang trình bày" }, "web_default_device_name": "%(appName)s: %(browserName)s trên %(osName)s", - "welcome_to_element": "Chào mừng tới Element", "widget": { "added_by": "widget được thêm bởi", "capabilities_dialog": { diff --git a/apps/web/src/i18n/strings/zh_Hans.json b/apps/web/src/i18n/strings/zh_Hans.json index baa3c19ae0..9c471ec469 100644 --- a/apps/web/src/i18n/strings/zh_Hans.json +++ b/apps/web/src/i18n/strings/zh_Hans.json @@ -1,24 +1,29 @@ { "a11y": { - "jump_first_invite": "跳转至第一个邀请。", + "emoji_picker": "Emoji 选择器", + "jump_first_invite": "跳转到首个邀请", + "message_composer": "消息编辑器", "n_unread_messages": { - "other": "%(count)s 个未读消息。", - "one": "1 个未读消息。" + "one": "1 个未读消息", + "other": "%(count)s 个未读消息。" }, "n_unread_messages_mentions": { - "other": "包括提及在内有 %(count)s 个未读消息。", - "one": "1 个未读提及。" + "one": "1 个未读提及", + "other": "%(count)s 个包含提及在内的未读消息" }, + "recent_rooms": "最近的房间", "room_name": "房间 %(name)s", + "room_status_bar": "房间状态栏", + "seek_bar_label": "音频搜索栏", "unread_messages": "未读消息。", "user_menu": "用户菜单" }, - "a11y_jump_first_unread_room": "跳转至第一个未读房间。", + "a11y_jump_first_unread_room": "跳转到首个未读房间。", "action": { "accept": "接受", "add": "添加", - "add_existing_room": "添加现有的房间", - "add_people": "加人", + "add_existing_room": "添加现有房间", + "add_people": "添加人员", "apply": "应用", "approve": "批准", "ask_to_join": "申请加入", @@ -39,6 +44,8 @@ "create_a_room": "创建房间", "create_account": "创建账户", "decline": "拒绝", + "decline_and_block": "拒绝并屏蔽", + "decline_invite": "拒绝邀请", "delete": "删除", "deny": "拒绝", "disable": "禁用", @@ -51,15 +58,15 @@ "enter_fullscreen": "进入全屏", "exit_fullscreeen": "退出全屏", "expand": "展开", - "explore_public_rooms": "查找公开房间", + "explore_public_rooms": "浏览公共房间", "explore_rooms": "查找房间", "export": "导出", "forward": "转发", "go": "前往", - "go_back": "返回", - "got_it": "知道了", - "hide_advanced": "隐藏高级", - "hold": "挂起", + "go_back": "后退", + "got_it": "明白", + "hide_advanced": "隐藏高级选项", + "hold": "保持", "ignore": "忽略", "import": "导入", "invite": "邀请", @@ -69,7 +76,7 @@ "learn_more": "了解更多", "leave": "离开", "leave_room": "离开房间", - "logout": "登出", + "logout": "注销", "manage": "管理", "maximise": "最大化", "mention": "提及", @@ -77,53 +84,56 @@ "new_room": "新建房间", "new_video_room": "新视频房间", "next": "下一个", - "no": "不", + "no": "否", "ok": "确定", "open": "打开", - "pin": "别针", + "pin": "置顶", "proceed": "继续", - "quote": "引述", + "quote": "引用", "react": "回应", "refresh": "刷新", "register": "注册", - "reload": "重加载", + "reload": "重新载入", "remove": "移除", "rename": "重命名", "reply": "回复", "reply_in_thread": "在消息列中回复", "report_content": "举报内容", + "report_room": "举报房间", "resend": "重新发送", "reset": "重置", "resume": "恢复", "retry": "重试", - "review": "开始验证", - "revoke": "撤销", + "review": "审阅", + "revoke": "撤消", "save": "保存", "search": "搜索", - "send_report": "发送报告", + "send_report": "发送举报", + "set_avatar": "设置个人资料图像", "share": "分享", "show": "显示", - "show_advanced": "显示高级", + "show_advanced": "显示高级选项", "show_all": "显示全部", "sign_in": "登录", - "sign_out": "注销", + "sign_out": "移除此设备", "skip": "跳过", "start": "开始", "start_chat": "开始聊天", - "start_new_chat": "开始新的聊天", + "start_new_chat": "开始新聊天", "stop": "停止", "submit": "提交", "subscribe": "订阅", "transfer": "传输", "trust": "信任", "try_again": "重试", - "unban": "解除封禁", - "unignore": "取消忽略", + "unban": "解封", + "unignore": "解除忽略", "unpin": "取消置顶", - "unsubscribe": "取消订阅", + "unsubscribe": "退订", "update": "更新", - "upgrade": "升级加密", + "upgrade": "升级", "upload": "上传", + "upload_file": "上传文件", "verify": "验证", "view": "查看", "view_all": "查看全部", @@ -131,334 +141,398 @@ "view_message": "查看消息", "view_source": "查看源码", "yes": "是", + "yes_dismiss": "是,忽略", "zoom_in": "放大", "zoom_out": "缩小" }, "analytics": { - "accept_button": "没问题", - "bullet_1": "我们不会记录或配置任何账户数据", - "bullet_2": "我们不会与第三方共享信息", - "consent_migration": "你之前同意与我们分享匿名使用数据。我们正在更新其工作方式。", - "disable_prompt": "您可以随时在设置中关闭此功能", + "accept_button": "良好", + "bullet_1": "我们记录或分析任何个人数据", + "bullet_2": "我们分享数据给第三方", + "consent_migration": "你已于之前同意我们分享匿名使用情况数据。我们正在更新其运作方式。", + "disable_prompt": "你可以随时在设置中关闭此选项。", "enable_prompt": "帮助改进 %(analyticsOwner)s", - "learn_more": "共享匿名数据帮助我们发现问题。无个人数据。 没有第三方。了解更多", - "privacy_policy": "你可以在此处阅读我们所有的条款", - "pseudonymous_usage_data": "通过共享匿名使用数据,帮助我们发现问题并改进%(analyticsOwner)s。为了了解人们如何使用多台设备,我们将生成一个由您的设备共享的随机标识符。", - "shared_data_heading": "以下数据之一可能被分享:" + "learn_more": "分享匿名数据已帮助我们识别问题。不涉及个人隐私及第三方。了解更多", + "privacy_policy": "你可以阅读我们的所有条款 点击此处", + "pseudonymous_usage_data": "通过分享匿名使用数据,帮助我们发现问题并改进 %(analyticsOwner)s。为了解用户如何使用多台设备,我们将生成一个由你的设备共享的随机标识符。", + "shared_data_heading": "以下任何数据都可能被分享:" }, "auth": { - "3pid_in_use": "该电子邮件地址或电话号码已被使用。", - "account_clash": "你的新账户(%(newAccountId)s)已注册,但你已经登录了一个不同的账户(%(loggedInUserId)s)。", - "account_clash_previous_account": "用之前的账户继续", + "3pid_in_use": "该邮件地址或电话号码已被使用。", + "account_clash": "你的新账户 (%(newAccountId)s) 已注册,但你已登录到其它账户 (%(loggedInUserId)s)。", + "account_clash_previous_account": "使用上一个账户继续", "account_deactivated": "此账户已被停用。", - "autodiscovery_generic_failure": "从服务器获取自动发现配置时失败", - "autodiscovery_hs_incompatible": "您的服务器版本太旧,不支持所需的最低API版本。请与服务器所有者联系,或者升级您的服务器。", - "autodiscovery_invalid": "无效的家服务器搜索响应", - "autodiscovery_invalid_hs": "家服务器链接不像是有效的 Matrix 家服务器", - "autodiscovery_invalid_hs_base_url": "m.homeserver 的 base_url 无效", - "autodiscovery_invalid_is": "身份服务器链接不像是有效的身份服务器", - "autodiscovery_invalid_is_base_url": "m.identity_server 的 base_url 无效", - "autodiscovery_invalid_is_response": "无效的身份服务器搜索响应", - "autodiscovery_invalid_json": "无效的 JSON", - "autodiscovery_no_well_known": "找不到.well-known JSON文件", - "autodiscovery_unexpected_error_hs": "解析家服务器配置时发生未知错误", - "autodiscovery_unexpected_error_is": "解析身份服务器配置时发生未知错误", - "captcha_description": "此家服务器想要确认你不是机器人。", - "change_password_action": "修改密码", - "change_password_confirm_invalid": "密码不匹配", + "autodiscovery_generic_failure": "从服务器获取自动发现配置失败", + "autodiscovery_hs_incompatible": "你的主服务器版本太旧,不支持所需的最低 API 版本。请联系你的服务器所有者或升级你的服务器。", + "autodiscovery_invalid": "主服务器发现响应无效", + "autodiscovery_invalid_hs": "主服务器 URL 似乎不是有效的 Matrix 主服务器", + "autodiscovery_invalid_hs_base_url": "“m.homeserver”中的“base_url”无效", + "autodiscovery_invalid_is": "身份服务器 URL 似乎不是有效的身份服务器", + "autodiscovery_invalid_is_base_url": "“m.identity_server”中的“base_url”无效", + "autodiscovery_invalid_is_response": "身份服务器发现响应无效", + "autodiscovery_invalid_json": "JSON 无效", + "autodiscovery_no_well_known": ".well-known JSON 文件未找到", + "autodiscovery_unexpected_error_hs": "解析主服务器配置时出现未知错误", + "autodiscovery_unexpected_error_is": "解析身份服务器配置时出现未知错误", + "captcha_description": "此主服务器需要确认你是否为机器人。", + "change_password_action": "更改密码", + "change_password_confirm_invalid": "两次输入的密码不匹配", "change_password_confirm_label": "确认密码", "change_password_current_label": "当前密码", "change_password_empty": "密码不能为空", "change_password_error": "更改密码时出错:%(error)s", - "change_password_mismatch": "两次输入的新密码不符", - "change_password_new_label": "新密码", - "check_email_explainer": "按已发到%(email)s的说明操作", - "check_email_resend_prompt": "没收到?", - "check_email_resend_tooltip": "验证链接电子邮件已重新发送!", - "check_email_wrong_email_button": "重新输入电子邮件地址", - "check_email_wrong_email_prompt": "电子邮件地址错误?", + "change_password_mismatch": "新密码不匹配", + "change_password_new_label": "新密码不匹配", + "check_email_explainer": "按说明发送到 %(email)s", + "check_email_resend_prompt": "未收到?", + "check_email_resend_tooltip": "验证链接邮件已重新发送!", + "check_email_wrong_email_button": "重新输入邮件地址", + "check_email_wrong_email_prompt": "邮件地址有误?", "continue_with_idp": "使用 %(provider)s 继续", "continue_with_sso": "使用 %(ssoButtons)s 继续", - "country_dropdown": "国家下拉菜单", + "country_dropdown": "国家与地区下拉菜单", "create_account_prompt": "新来的?创建账户", "create_account_title": "创建账户", - "email_discovery_text": "使用电子邮箱以选择性地被现有联系人搜索。", - "email_field_label": "电子邮箱", - "email_field_label_invalid": "看起来不像有效的邮件地址", - "email_field_label_required": "输入邮箱地址", - "email_help_text": "添加电子邮箱以重置你的密码。", - "email_phone_discovery_text": "使用电子邮箱或电话以选择性地被现有联系人搜索。", - "enter_email_explainer": "%(homeserver)s 会向您发送一个验证链接,让您重置密码。", - "enter_email_heading": "输入您的邮箱以重置您的密码", - "failed_connect_identity_server": "无法连接到身份服务器", - "failed_connect_identity_server_other": "你可以登录,但部分功能在身份服务器重新上线之前不可用。如果持续看到此警告,请检查配置或联系服务器管理员。", - "failed_connect_identity_server_register": "你可以注册,但部分功能在身份服务器重新上线之前不可用。如果持续看到此警告,请检查配置或联系服务器管理员。", - "failed_connect_identity_server_reset_password": "你可以重置密码,但部分功能在身份服务器重新上线之前不可用。如果持续看到此警告,请检查配置或联系服务器管理员。", - "failed_homeserver_discovery": "无法执行家服务器搜索", + "email_discovery_text": "使用邮件可选择让现有联系人发现。", + "email_field_label": "邮件", + "email_field_label_invalid": "似乎不是有效的邮件地址", + "email_field_label_required": "输入邮件地址", + "email_help_text": "添加邮件地址以用于重置密码", + "email_phone_discovery_text": "使用邮件地址或电话号码可选择让现有联系人发现。", + "enter_email_explainer": "%(homeserver)s 将向你发送验证链接以重置密码。", + "enter_email_heading": "输入邮件地址以重置密码", + "failed_connect_identity_server": "无法连接身份服务器", + "failed_connect_identity_server_other": "你可以登录,但某些功能在身份服务器恢复正常之前将不可用。如果你持续看到此警告,请检查你的配置或联系服务器管理员。", + "failed_connect_identity_server_register": "你可以注册,但某些功能在身份服务器恢复正常之前将不可用。如果你持续看到此警告,请检查你的配置或联系服务器管理员。", + "failed_connect_identity_server_reset_password": "你可以重置密码,但某些功能在身份服务器恢复正常之前将不可用。如果你持续看到此警告,请检查你的配置或联系服务器管理员。", + "failed_homeserver_discovery": "无法执行主服务器发现", "failed_query_registration_methods": "无法查询支持的注册方法。", "failed_soft_logout_auth": "重新认证失败", - "failed_soft_logout_homeserver": "由于家服务器的问题,重新认证失败", - "forgot_password_email_invalid": "电子邮件地址似乎无效。", - "forgot_password_email_required": "必须输入和你账户关联的邮箱地址。", - "forgot_password_prompt": "忘记你的密码了吗?", - "forgot_password_send_email": "发送重置连接", - "identifier_label": "登录方式", + "failed_soft_logout_homeserver": "由于服务器的问题,重新认证失败", + "forgot_password_email_invalid": "邮件地址似乎无效。", + "forgot_password_email_required": "必须输入与你的账户关联的邮件地址", + "forgot_password_prompt": "忘记密码?", + "forgot_password_send_email": "发送邮件", + "identifier_label": "登录选项", "incorrect_credentials": "用户名或密码错误。", - "incorrect_credentials_detail": "请注意,你正在登录 %(hs)s,而非 matrix.org。", - "incorrect_password": "密码错误", + "incorrect_credentials_detail": "请留意你登录的服务器为 %(hs)s 而不是 matrix.org", + "incorrect_password": "密码不正确", "log_in_new_account": "登录到你的新账户。", "logout_dialog": { - "description": "你确定要登出吗?", + "description": "你确定要注销吗?", "megolm_export": "手动导出密钥", - "setup_key_backup_title": "你将失去你的加密消息的访问权", - "setup_secure_backup_description_1": "加密消息已使用端到端加密保护。只有你和拥有密钥的收件人可以阅读这些消息。", - "setup_secure_backup_description_2": "当你登出时,这些密钥会从此设备删除。这意味着你将无法查阅已加密消息,除非你在其他设备上有那些消息的密钥,或者已将其备份到服务器。", - "skip_key_backup": "我不想要我的加密消息" + "setup_key_backup_title": "你将失去加密消息的访问权", + "setup_secure_backup_description_1": "加密消息采用端到端加密技术确保安全。只有你与收件人拥有读取这些消息的密钥。", + "setup_secure_backup_description_2": "当你移除此设备时,这些密钥将从此设备中删除,这意味着你将无法读取加密消息,除非你在其他设备上拥有这些密钥,或者将它们备份到服务器。", + "skip_key_backup": "我要丢弃加密消息" }, - "misconfigured_body": "跟你的%(brand)s管理员确认你的配置不正确或重复的条目。", - "misconfigured_title": "你的 %(brand)s 配置有错误", - "msisdn_field_description": "别的用户可以使用你的联系人详情邀请你加入房间", + "misconfigured_body": "请 %(brand)s 的管理员检查你的配置中是否存在错误或重复的条目。", + "misconfigured_title": "你的 %(brand)s 配置错误", + "mobile_create_account_title": "你即将在 %(hsName)s 创建账户", + "msisdn_field_description": "其他用户可以使用你的联系方式邀请你进入房间", "msisdn_field_label": "电话", - "msisdn_field_number_invalid": "电话号码看起来不太对,请检查并重试", + "msisdn_field_number_invalid": "电话号码看起来不正确,请检查并重试", "msisdn_field_required_invalid": "输入电话号码", - "no_hs_url_provided": "未输入家服务器链接", + "no_hs_url_provided": "未提供主服务器 URL", "oidc": { - "error_title": "我们无法使你登入", - "generic_auth_error": "验证时出了问题。前往登录页面并重试。", - "missing_or_invalid_stored_state": "我们已要求浏览器记住你使用的家服务器,但不幸的是你的浏览器已忘记。请前往登录页面重试。" + "error_title": "我们无法让你登录", + "generic_auth_error": "身份验证期间出现问题。请转到登录页面并重试。", + "missing_or_invalid_stored_state": "我们要求浏览器记住你用于登录的主服务器,但很遗憾,你的浏览器忘记了它。请转到登录页面并重试。" }, - "password_field_keep_going_prompt": "继续前进……", + "password_field_keep_going_prompt": "保持…", "password_field_label": "输入密码", - "password_field_strong_label": "不错,是个强密码!", - "password_field_weak_label": "密码允许但不安全", + "password_field_strong_label": "很好,高强度密码!", + "password_field_weak_label": "允许使用密码,但不安全", "phone_label": "电话", "phone_optional_label": "电话号码(可选)", "qr_code_login": { - "completing_setup": "完成新设备的设置" + "check_code_explainer": "这将验证与其它设备的关联是否安全。", + "check_code_heading": "输入其它设备上显示的数字", + "check_code_input_label": "2 位数代码", + "check_code_mismatch": "数字不匹配", + "completing_setup": "完成新设备的设置", + "error_etag_missing": "发生意外错误。这可能是由于浏览器扩展、代理服务器或服务器配置错误造成的。", + "error_expired": "登录已过期。请重试。", + "error_expired_title": "登录未及时完成", + "error_insecure_channel_detected": "无法与新设备建立安全连接。你的现有设备仍然安全,无需担心。", + "error_insecure_channel_detected_instructions": "现在怎么办?", + "error_insecure_channel_detected_instructions_1": "如果是网络问题,请尝试使用二维码再次登录到其它设备。", + "error_insecure_channel_detected_instructions_2": "如果你遇到同样的问题,请尝试其它 Wi-Fi 网络或使用移动数据流量。", + "error_insecure_channel_detected_instructions_3": "如果未能生效,请手动登录", + "error_insecure_channel_detected_title": "不安全连接", + "error_other_device_already_signed_in": "你不需要做其他任何事情。", + "error_other_device_already_signed_in_title": "你的其它设备已登录", + "error_rate_limited": "短时间内尝试次数过多。请稍后再试。", + "error_unexpected": "发生意外错误。连接你其它设备的请求已被取消。", + "error_unsupported_protocol": "此设备不支持使用二维码登录其它设备。", + "error_unsupported_protocol_title": "其它设备不兼容", + "error_user_cancelled": "已在另一设备上取消登录。", + "error_user_cancelled_title": "登录请求已取消", + "error_user_declined": "你或账户提供者拒绝了登录请求。", + "error_user_declined_title": "登录被拒绝", + "follow_remaining_instructions": "按剩余说明操作", + "open_element_other_device": "在你的其它设备上打开 %(brand)s", + "point_the_camera": "扫描此处显示的二维码", + "scan_code_instruction": "使用其它设备扫描二维码", + "scan_qr_code": "使用二维码登录", + "security_code": "安全代码", + "security_code_prompt": "如有要求,请在其它设备上输入以下代码。", + "select_qr_code": "选择“%(scanQRCode)s”", + "unsupported_explainer": "你的账户提供者不支持通过二维码登录到新设备。", + "unsupported_heading": "二维码不受支持", + "waiting_for_device": "等待设备登录" }, "register_action": "创建账户", "registration": { - "continue_without_email_description": "请注意,如果你不添加电子邮箱并且忘记密码,你将永远失去对你账户的访问权。", - "continue_without_email_field_label": "电子邮箱(可选)", - "continue_without_email_title": "不使用电子邮箱并继续" + "continue_without_email_description": "请注意,如果你未添加邮件地址并忘记了密码,你可能会永久失去账户访问权。", + "continue_without_email_field_label": "邮件地址(可选)", + "continue_without_email_title": "在不使用邮件的情况下继续" }, - "registration_disabled": "此家服务器已禁止注册。", - "registration_msisdn_field_required_invalid": "输入电话号码(此家服务器上必须)", + "registration_disabled": "此主服务器已禁用注册。", + "registration_msisdn_field_required_invalid": "输入电话号码(在此主服务器为必需)", "registration_successful": "注册成功", - "registration_username_in_use": "该名称已被占用。 尝试另一个,或者如果是您,请在下面登录。", - "registration_username_unable_check": "无法检查用户名是否已被使用。稍后再试。", - "registration_username_validation": "仅使用小写字母,数字,横杠和下划线", + "registration_username_in_use": "该用户名已被使用。请尝试其它用户名,如果是你本人,请在下方登录。", + "registration_username_unable_check": "无法检查用户名是否被占用。稍后再试。", + "registration_username_validation": "只能使用小写字母、数字、破折号与下划线", "reset_password": { - "devices_logout_success": "你已登出全部设备,并将不再收到推送通知。要重新启用通知,请在每台设备上再次登入。", - "other_devices_logout_warning_1": "登出你的设备会删除存储在其上的消息加密密钥,使加密的聊天历史不可读。", - "other_devices_logout_warning_2": "若想保留对加密房间的聊天历史的访问权,请设置密钥备份或从其他设备导出消息密钥,然后再继续。", + "confirm_new_password": "确认新密码", + "devices_logout_success": "你已移除所有设备,将不再接收推送通知。要重新启用通知,请在每台设备上重新登录。", + "other_devices_logout_warning_1": "移除设备将删除存储在其中的消息加密密钥,从而使加密的聊天历史无法读取。", + "other_devices_logout_warning_2": "如果你想保留对加密房间中聊天记录的访问权限,请获取恢复密钥或从其它设备导出消息密钥,然后再继续。", "password_not_entered": "必须输入新密码。", - "passwords_mismatch": "新密码必须互相匹配。", - "reset_successful": "你的密码已重置。", - "return_to_login": "返回登录页面" + "passwords_mismatch": "两次输入的新密码必须匹配。", + "rate_limit_error": "短时间内尝试次数过多。请稍后再试。", + "rate_limit_error_with_time": "短时间内尝试次数过多。请于 %(timeout)s 秒后重试。", + "reset_successful": "密码已被重置。", + "return_to_login": "回到登录屏幕", + "sign_out_other_devices": "移除其它设备" }, + "reset_password_action": "重置密码", "reset_password_button": "忘记密码?", - "reset_password_email_field_description": "使用邮件地址恢复你的账户", - "reset_password_email_field_required_invalid": "输入邮件地址(此家服务器上必须)", - "reset_password_email_not_associated": "你的电子邮件地址似乎未与服务器上的Matrix ID关联。", - "reset_password_email_not_found_title": "未找到此邮箱地址", - "server_picker_custom": "其他自定义服务器", - "server_picker_description": "你可以使用自定义服务器选项来指定不同的家服务器URL以登录其他Matrix服务器。这让你能把%(brand)s和不同家服务器上的已有Matrix账户搭配使用。", - "server_picker_description_matrix.org": "免费加入最大的公共服务器,成为数百万用户中的一员", - "server_picker_dialog_title": "决定账户托管位置", - "server_picker_explainer": "使用你的Matrix服务器,或自己架设一个。", - "server_picker_failed_validate_homeserver": "无法验证家服务器", - "server_picker_intro": "我们将您可以托管账户的地方称为“服务器组”。", - "server_picker_invalid_url": "URL 无效", - "server_picker_learn_more": "关于家服务器", - "server_picker_matrix.org": "Matrix.org 是世界上最大的公共家服务器,因此对许多人来说是一个好地方。", - "server_picker_required": "指定家服务器", - "server_picker_title": "登录你的家服务器", + "reset_password_email_field_description": "使用邮件地址以恢复账户", + "reset_password_email_field_required_invalid": "输入邮件地址(在该主服务器为必需)", + "reset_password_email_not_associated": "你的邮件地址似乎未与此主服务器上的 Matrix ID 关联。", + "reset_password_email_not_found_title": "此邮件地址未找到", + "reset_password_title": "重置密码", + "server_picker_custom": "其它主服务器", + "server_picker_description": "你可以使用自定义服务器选项,通过指定不同的主服务器网址登录其它 Matrix 服务器。这样你就可以通过现有的 Matrix 账户在其它主服务器上使用 %(brand)s。", + "server_picker_description_matrix.org": "免费加入数百万人规模,最大的公共服务器", + "server_picker_dialog_title": "决定你的账户托管在哪里", + "server_picker_explainer": "如果有首选的 Matrix 主服务器,请使用它,或托管自己的服务器。", + "server_picker_failed_validate_homeserver": "无法验证主服务器", + "server_picker_intro": "我们将可以托管账户的地方称为 “主服务器”。", + "server_picker_invalid_url": "无效 URL", + "server_picker_learn_more": "关于主服务器", + "server_picker_matrix.org": "Matrix.org 是世界上最大的公共主服务器,因此对许多人来说都是一个不错的选择。", + "server_picker_required": "指定主服务器", + "server_picker_title": "登录到主服务器", "server_picker_title_default": "服务器选项", "server_picker_title_registration": "账户托管于", - "session_logged_out_description": "出于安全考虑,此会话已被注销。请重新登录。", - "session_logged_out_title": "已退出登录", + "session_logged_out_description": "为安全起见,此设备已被移除。请重新登录。", + "session_logged_out_title": "会话已移除", "set_email": { - "description": "这将允许你重置你的密码和接收通知。", - "verification_pending_description": "请检查你的电子邮箱并点击里面包含的链接。完成时请点击继续。", - "verification_pending_title": "验证等待中" + "description": "这将允许你重置密码并接收通知。", + "verification_pending_description": "请查看邮件并点击其中的链接。完成后点击“继续”。", + "verification_pending_title": "等待验证" }, - "set_email_prompt": "你想要设置一个邮箱地址吗?", - "sign_in_description": "使用你的账户继续。", - "sign_in_instead": "跳转到登录", - "sign_in_instead_prompt": "已有账户?在此登录", + "set_email_prompt": "你要设置邮件地址吗?", + "sign_in_description": "使用你的账户以继续。", + "sign_in_instead": "其它登录方式", + "sign_in_instead_prompt": "已有账户?登录", "sign_in_or_register": "登录或创建账户", - "sign_in_or_register_description": "使用已有账户或创建一个新账户。", - "sign_in_prompt": "有账户了?登录", - "sign_in_with_sso": "使用单点登录", + "sign_in_or_register_description": "使用你的现有账户或创建一个新账户继续。", + "sign_in_prompt": "已有账户?登录", + "sign_in_with_sso": "以单点模式登录", + "signing_in": "正在登录…", "soft_logout": { "clear_data_button": "清除所有数据", - "clear_data_description": "清除此设备中的所有数据是永久的。加密消息会丢失,除非其密钥已被备份。", - "clear_data_title": "是否清除此设备中的所有数据?" + "clear_data_description": "清除此会话中的所有数据是永久性的。除非备份了密钥,否则加密消息将会丢失。", + "clear_data_title": "清除此会话中的所有数据?" }, - "soft_logout_heading": "你已登出", - "soft_logout_intro_password": "输入你的密码以登录并重新获取访问你账户的权限。", - "soft_logout_intro_sso": "请登录以重新获取访问你账户的权限。", - "soft_logout_intro_unsupported_auth": "你不能登录到你的账户。请联系你的家服务器管理员以获取更多信息。", + "soft_logout_heading": "你已注销", + "soft_logout_intro_password": "输入密码登录并重新访问你的账户。", + "soft_logout_intro_sso": "登录并重新获得对账户的访问权。", + "soft_logout_intro_unsupported_auth": "你无法登录账户。请联系主服务器管理员了解更多信息。", "soft_logout_subheading": "清除个人数据", + "soft_logout_warning": "警告:你的个人数据(包括加密密钥)仍存储在此会话中。如果你结束使用此会话或想要登录其它账户,请清除它。", "sso": "单点登录", - "sso_complete_in_browser_dialog_title": "转到您的浏览器以完成登录", - "sso_failed_missing_storage": "我们已请求浏览器记住你使用的服务器,但是你的浏览器貌似已经忘记了。请前往登录页面重试。", + "sso_complete_in_browser_dialog_title": "转到浏览器以完成登录", + "sso_failed_missing_storage": "我们要求浏览器记住你用于登录的主服务器,但很遗憾,你的浏览器忘记了它。请转到登录页面并重试。", "sso_or_username_password": "%(ssoButtons)s 或 %(usernamePassword)s", - "sync_footer_subtitle": "如果你加入了很多房间,可能会消耗一些时间", + "sync_footer_subtitle": "如果你加入了很多房间,这可能需要一段时间。", + "syncing": "正在同步…", "uia": { "code": "代码", - "email": "要创建账户,请打开我们刚刚发送到%(emailAddress)s的电子邮件里的链接。", - "email_auth_header": "检查你的电子邮件以继续", - "email_resend_prompt": "没收到吗?重新发送", - "email_resent": "已重新发送!", + "email": "要创建你的账户,请打开我们刚刚发送至 %(emailAddress)s 的邮件中的链接。", + "email_auth_header": "查看邮箱以继续", + "email_resend_prompt": "没有收到?重新发送", + "email_resent": "重新发送!", "fallback_button": "开始认证", - "msisdn": "一封短信已发送到 %(msisdn)s", - "msisdn_token_incorrect": "令牌错误", - "msisdn_token_prompt": "请输入其包含的代码:", - "password_prompt": "在下方输入账户密码以确认你的身份。", - "recaptcha_missing_params": "在服务器配置中缺少验证码公钥。请将此问题报告给你的服务器管理员。", - "registration_token_prompt": "输入由服务器管理员所提供的注册密钥。", - "sso_body": "使用单一登入证明你的身份,以确认添加此电子邮件地址。", - "sso_failed": "确认你的身份时出了一点问题。取消并重试。", - "sso_postauth_body": "点击下方按钮确认你的身份。", + "mas_cross_signing_reset_cta": "在账户中继续", + "mas_cross_signing_reset_description": "你即将前往 %(serverName)s 账户重置数字身份。完成账户重置后,请返回此处并点击“重试”。", + "mas_cross_signing_reset_title": "转到你的账户以重置数字身份", + "msisdn": "已向 %(msisdn)s 发送文本消息", + "msisdn_token_incorrect": "Token 不正确", + "msisdn_token_prompt": "请输入其中包含的代码:", + "password_prompt": "请在下面输入账户密码以确认身份。", + "recaptcha_missing_params": "主服务器配置中缺少 CAPTCHA 公钥。请将此问题报告给主服务器管理员。", + "registration_token_label": "注册 Token", + "registration_token_prompt": "输入主服务器管理员提供的注册 Token。", + "sso_body": "请确认使用单点登录添加此邮件地址以证明身份。", + "sso_failed": "确认身份时出现问题。取消并重试。", + "sso_postauth_body": "点击以下按钮确认身份。", "sso_postauth_title": "确认以继续", - "sso_preauth_body": "要继续,请使用单点登录证明你的身份。", + "sso_preauth_body": "要继续,请使用单点登录以证明身份。", "sso_title": "使用单点登录继续", - "terms": "请阅读并接受此服务器的政策:", - "terms_invalid": "请阅读并接受此服务器的所有政策" + "terms": "请阅读并接受主服务器的政策:", + "terms_invalid": "请阅读并接受主服务器的所有政策" }, - "unsupported_auth": "此服务器未提供客户端支持的任何登录流程。", - "unsupported_auth_email": "此家服务器不支持使用电子邮箱地址登录。", - "unsupported_auth_msisdn": "此服务器不支持使用电话号码认证。", + "unsupported_auth": "此主服务器不提供此客户端支持的任何登录流程。", + "unsupported_auth_email": "此主服务器不支持使用邮件地址登录。", + "unsupported_auth_msisdn": "此服务器不支持使用电话号码进行身份验证。", "username_field_required_invalid": "输入用户名", - "username_in_use": "用户名已被占用,请尝试使用其他用户名。" + "username_in_use": "此用户名已被使用,请更换。", + "verify_email_explainer": "在重置密码之前,我们需要确认你的身份。请点击我们刚刚发送到 %(email)s 中的链接。", + "verify_email_heading": "验证邮件地址以继续" }, "bug_reporting": { - "additional_context": "如果有额外的上下文可以帮助我们分析问题,比如你当时在做什么、房间 ID、用户 ID 等等,请将其列于此处。", - "before_submitting": "在提交日志之前,你必须创建一个GitHub issue 来描述你的问题。", - "collecting_information": "正在收集应用版本信息", + "additional_context": "如果有其它有助于分析问题的上下文(例如你当时在做什么、房间 ID、用户 ID 等),请在此处包含这些内容。", + "before_submitting": "建议你创建 GitHub Issue,以确保你的报告被审核。", + "collecting_information": "正在收集 App 版本信息", "collecting_logs": "正在收集日志", - "create_new_issue": "请在 GitHub 上创建一个新 issue 以便我们调查此错误。", - "description": "调试日志包含应用使用数据,其中包括你的用户名、你访问过的房间的别名或ID、你上次与哪些UI元素互动、还有其它用户的用户名。但不包含消息。", + "create_new_issue": "请在 GitHub 上创建新 issue,以便我们调查此错误。", + "description": "调试日志包含 App 使用数据、你的用户名、访问过的房间 ID 或其别名、上次与之交互的 UI 元素以及其他用户的用户名。它们不包含消息。", "download_logs": "下载日志", "downloading_logs": "正在下载日志", - "error_empty": "请告诉我们哪里出错了,或最好创建一个 GitHub issue 来描述此问题。", + "error_empty": "请告诉我们出了什么问题,或者更好的做法是创建一个 GitHub issue 来描述问题。", + "failed_download_logs": "调试日志下载失败: ", + "failed_send_logs_causes": { + "disallowed_app": "你的 Bug 报告已被拒绝。Rageshake 服务器不支持此应用程序。", + "rejected_generic": "你的 Bug 报告已被拒绝。Rageshake 服务器由于策略原因拒绝了报告内容。", + "rejected_recovery_key": "你的 Bug 报告由于安全原因被拒绝,因为它包含恢复密钥。", + "rejected_version": "你的 Bug 报告被拒绝,因为你正在运行的版本太旧。", + "server_unknown_error": "Rageshake 服务器遇到未知错误,无法处理报告。", + "unknown_error": "日志发送失败。" + }, "github_issue": "GitHub 上的 issue", - "introduction": "若你通过GitHub提交bug,则调试日志能帮助我们追踪问题。 ", - "log_request": "要帮助我们防止其以后发生,请给我们发送日志。", - "logs_sent": "日志已发送", - "matrix_security_issue": "要报告 Matrix 相关的安全问题,请阅读 Matrix.org 的安全公开策略。", - "preparing_download": "正在准备下载日志", + "introduction": "如果你通过 GitHub 提交 bug,调试日志可以帮助我们跟踪问题。", + "log_request": "为帮助我们今后避免这种情况,请向我们发送日志。", + "logs_sent": "发送日志", + "matrix_security_issue": "要报告与 Matrix 相关的安全问题,请阅读 Matrix.org 安全披露政策。", + "preparing_download": "准备下载日志", "preparing_logs": "正在准备发送日志", "send_logs": "发送日志", "submit_debug_logs": "提交调试日志", - "textarea_label": "提示", + "textarea_label": "内容", "thank_you": "谢谢!", - "title": "错误上报", - "unsupported_browser": "提醒:你的浏览器不被支持,所以你的体验可能不可预料。", + "title": "Bug 报告", + "unsupported_browser": "提醒:你的浏览器不受支持,因此你的使用体验可能无法预测。", "uploading_logs": "正在上传日志", - "waiting_for_server": "正在等待服务器响应" + "waiting_for_server": "等待服务器响应" }, - "cannot_invite_without_identity_server": "无法在未设置身份服务器时邀请用户,你可以在“设置”里连接一个。", - "cannot_reach_homeserver": "无法连接到家服务器", - "cannot_reach_homeserver_detail": "确保你的网络连接稳定,或与服务器管理员联系", - "cant_load_page": "无法加载页面", - "chat_card_back_action_label": "返回聊天", + "cannot_invite_without_identity_server": "如果没有身份服务器,则无法通过邮件地址邀请用户。你可以在“设置”下连接到身份服务器。", + "cannot_reach_homeserver": "无法连接主服务器", + "cannot_reach_homeserver_detail": "确保具有良好的 Internet 连接,或联系服务器管理员", + "cant_load_page": "无法载入页面", + "chat_card_back_action_label": "回到聊天", "chat_effects": { - "confetti_description": "附加五彩纸屑发送", - "confetti_message": "发送五彩纸屑", - "fireworks_description": "附加烟火发送", - "fireworks_message": "发送烟火", - "hearts_description": "与爱心一起发送给定的消息", - "hearts_message": "发送爱心", - "rainfall_description": "附加降雨发送给定的消息", - "rainfall_message": "发送降雨", - "snowfall_description": "发送附加雪球的给定信息", - "snowfall_message": "发送雪球", - "spaceinvaders_description": "此消息带有空间主题化效果", - "spaceinvaders_message": "发送空间入侵者" + "confetti_description": "向指定消息发送“五彩纸屑”", + "confetti_message": "发送了“五彩纸屑”", + "fireworks_description": "向指定消息发送“烟花”", + "fireworks_message": "发送了“烟花”", + "hearts_description": "向指定消息发送“爱心”", + "hearts_message": "发送了“爱心”", + "rainfall_description": "向指定消息发送“下雨”", + "rainfall_message": "发送了“下雨”", + "snowfall_description": "向指定消息发送“下雪”", + "snowfall_message": "发送了“下雪”", + "spaceinvaders_description": "向指定消息发送具有“太空主题”的特效", + "spaceinvaders_message": "发送了“太空侵略者”" }, "common": { - "access_token": "访问令牌", - "accessibility": "无障碍功能", + "access_token": "访问 Token", + "accessibility": "易用性", "advanced": "高级", - "analytics": "统计分析服务", + "all_chats": "所有聊天", + "analytics": "分析", "and_n_others": { - "other": "和其他%(count)s个人……", - "one": "和其它一个..." + "one": "与另一个…", + "other": "以及剩余 %(count)s 个…" }, "appearance": "外观", "application": "应用", - "are_you_sure": "你确定吗?", + "are_you_sure": "是否确定?", "attachment": "附件", "authentication": "认证", "avatar": "头像", "beta": "beta", - "camera": "摄像头", + "camera": "相机", "cameras": "相机", - "capabilities": "功能", + "cancel": "取消", + "capabilities": "能力", "copied": "已复制!", - "credits": "感谢", + "credits": "鸣谢", "dark": "深色", "description": "描述", "deselect_all": "取消全选", "device": "设备", "edited": "已编辑", - "email_address": "邮箱地址", + "email_address": "邮件地址", "emoji": "表情符号", "encrypted": "已加密", "error": "错误", "faq": "常见问答集", - "favourites": "收藏夹", + "favourites": "收藏", "feedback": "反馈", - "filter_results": "过滤结果", + "filter_results": "筛选结果", "forward_message": "转发消息", "general": "通用", - "go_to_settings": "打开设置", - "guest": "游客", + "go_to_settings": "转到设置", + "guest": "访客", "help": "帮助", "historical": "历史", "home": "主页", - "homeserver": "家服务器", + "homeserver": "主服务器", "identity_server": "身份服务器", - "image": "图片", + "image": "图像", "integration_manager": "集成管理器", "joined": "已加入", "labs": "实验室", - "legal": "法律信息", + "legal": "法律", "light": "浅色", - "loading": "加载中...", + "loading": "正在载入…", "location": "位置", "low_priority": "低优先级", "matrix": "Matrix", "message": "消息", "message_layout": "消息布局", + "message_timestamp_invalid": "无效时间戳", "microphone": "麦克风", "model": "模型", + "moderation_and_safety": "尺度与安全", "modern": "现代", - "mute": "静音", + "mute": "静默", "n_members": { "one": "%(count)s 位成员", - "other": "%(count)s 位成员" + "other": "%(count)s 个成员" }, "n_rooms": { "one": "%(count)s 个房间", "other": "%(count)s 个房间" }, "name": "名称", - "no_results": "没有更多结果", - "no_results_found": "找不到结果", - "not_trusted": "不受信任的", - "off": "关闭", + "no_results": "没有结果", + "no_results_found": "未找到结果", + "not_trusted": "未被信任", + "off": "关", "offline": "离线", - "on": "打开", + "on": "开", "options": "选项", - "orphan_rooms": "其他房间", + "orphan_rooms": "其它房间", "password": "密码", - "people": "联系人", + "people": "人员", "preferences": "偏好", - "presence": "在线", + "presence": "线上状态", "preview_message": "嘿。你是最棒的!", "privacy": "隐私", "private": "私有", @@ -467,67 +541,75 @@ "profile": "个人资料", "public": "公共", "public_room": "公共房间", - "public_space": "公开空间", + "public_space": "公共空间", "qr_code": "二维码", "random": "随机", "reactions": "反应", - "report_a_bug": "反馈问题", + "recommended": "推荐", + "report_a_bug": "报告 Bug", "room": "房间", "room_name": "房间名称", "rooms": "房间", + "save": "保存", + "saved": "已保存", + "saving": "正在保存…", "secure_backup": "安全备份", "select_all": "全选", "server": "服务器", "settings": "设置", - "setup_secure_messages": "设置安全消息", + "setup_secure_messages": "设置安全消息传递", "show_more": "显示更多", - "someone": "某位用户", - "space": "空格", + "someone": "某人", + "space": "空间", "spaces": "空间", + "state_encryption_enabled": "实验性的状态加密已启用", "sticker": "贴纸", "stickerpack": "贴纸包", "success": "成功", "suggestions": "建议", "support": "支持", - "system_alerts": "系统警告", + "system_alerts": "系统警报", "theme": "主题", "thread": "消息列", "threads": "消息列", "timeline": "时间线", + "unavailable": "不可用", "unencrypted": "未加密", - "unmute": "取消静音", - "unnamed_room": "未命名的房间", + "unmute": "取消静默", + "unnamed_room": "未命名房间", "unnamed_space": "未命名空间", "unverified": "未验证", + "updating": "正在更新…", "user": "用户", - "user_avatar": "头像", + "user_avatar": "个人资料图像", "username": "用户名", "verified": "已验证", "version": "版本", "video": "视频", "video_room": "视频房间", "view_message": "查看消息", + "voice": "语音", "warning": "警告" }, "composer": { "autocomplete": { "@room_description": "通知房间全体成员", "command_a11y": "命令自动补全", - "command_description": "命令", - "emoji_a11y": "表情符号自动补全", + "command_description": "指令", + "emoji_a11y": "Emoji 自动补全", "notification_a11y": "通知自动补全", "notification_description": "房间通知", "room_a11y": "房间自动补全", - "space_a11y": "空间自动完成", + "space_a11y": "空间自动补全", "user_a11y": "用户自动补全", "user_description": "用户" }, "close_sticker_picker": "隐藏贴纸", "edit_composer_label": "编辑消息", - "format_bold": "粗体", + "format_bold": "加粗", "format_code_block": "代码块", "format_decrease_indent": "减少缩进", - "format_increase_indent": "添加缩进", + "format_increase_indent": "增加缩进", "format_inline_code": "代码", "format_insert_link": "插入链接", "format_italic": "斜体", @@ -536,567 +618,826 @@ "format_ordered_list": "有序列表", "format_strikethrough": "删除线", "format_underline": "下划线", - "format_unordered_list": "无序列表", + "format_unordered_list": "项目符号列表", + "formatting_toolbar_label": "格式化", "link_modal": { - "link_field_label": "链接" + "link_field_label": "链接", + "text_field_label": "文本", + "title_create": "创建链接", + "title_edit": "编辑链接" }, - "no_perms_notice": "你没有在此房间发送消息的权限", + "mode_plain": "隐藏文字格式化选项", + "mode_rich_text": "显示文字格式化选项", + "no_perms_notice": "你无权在此房间发送消息", "placeholder": "发送消息…", - "placeholder_encrypted": "发送加密消息……", + "placeholder_encrypted": "发送加密消息…", "placeholder_reply": "发送回复…", "placeholder_reply_encrypted": "发送加密回复…", - "placeholder_thread": "回复消息列……", - "placeholder_thread_encrypted": "回复加密的消息列……", + "placeholder_thread": "在消息列中回复…", + "placeholder_thread_encrypted": "回复加密消息列…", "poll_button": "投票", "poll_button_no_perms_description": "你无权在此房间启动投票。", "poll_button_no_perms_title": "需要权限", "replying_title": "正在回复", - "room_upgraded_link": "对话在这里继续。", - "room_upgraded_notice": "此房间已被取代,且不再活跃。", + "room_unencrypted": "位于此房间的消息非端到端加密", + "room_upgraded_link": "对话在此处继续。", + "room_upgraded_notice": "此房间已被取代,不再活跃。", "send_button_title": "发送消息", "send_button_voice_message": "发送语音消息", "send_voice_message": "发送语音消息", - "stop_voice_message": "停止录制", + "stop_voice_message": "停止录音", "voice_message_button": "语音消息" }, - "console_dev_note": "若你知道你正在做什么,Element是开源的,请务必看看我们的GitHub(https://github.com/vector-im/element-web/)并贡献!", - "console_scam_warning": "若某人告诉你在这里复制/粘贴某物,那你极有可能正被欺骗!", - "console_wait": "等等!", + "console_dev_note": "如果你知道你在做什么,Element 是开源的,请务必查看我们的 GitHub(https://github.com/vector-im/element-web/)并做出贡献!", + "console_scam_warning": "如果有人告诉你在这里复制或粘贴某些内容,那么你很有可能被诈骗!", + "console_wait": "请三思!", "create_room": { "action_create_room": "创建房间", "action_create_video_room": "创建视频房间", - "encrypted_video_room_warning": "你以后无法停用。房间将会加密但是嵌入的通话不会。", - "encrypted_warning": "之后你无法停用。桥接和大多数机器人也不能工作。", - "encryption_forced": "你的服务器要求私有房间得启用加密。", + "encrypted_video_room_warning": "你以后无法禁用此功能。房间将被加密,但嵌入式通话不会加密。", + "encrypted_warning": "此项之后无法禁用。会导致桥接器与大多数机器人暂不可用。", + "encryption_forced": "服务器要求在私有房间中启用加密。", "encryption_label": "启用端到端加密", - "error_title": "创建房间失败", - "generic_error": "当前服务器可能处于不可用或过载状态,或者你遇到了一个 bug。", - "join_rule_change_notice": "你可以随时从房间设置中更改此设置。", + "error_title": "房间创建失败", + "generic_error": "服务器可能不可用、超载或遇到错误。", + "join_rule_change_notice": "你可以随时在房间设置中更改此项。", "join_rule_invite": "私有房间(仅邀请)", - "join_rule_invite_label": "只有被邀请的人才能找到并加入这个房间。", - "join_rule_public_label": "任何人都可以找到并加入这个房间。", - "join_rule_public_parent_space_label": "任何人都可以找到并加入这个房间,而不仅仅是 的成员。", - "join_rule_restricted": "对空间成员可见", - "join_rule_restricted_label": " 中的每个人都可以找到并加入这个房间。", - "name_validation_required": "请输入房间名称", - "room_visibility_label": "房间可见度", - "title_private_room": "创建一个私人房间", - "title_public_room": "创建公开房间", + "join_rule_invite_label": "只有受邀人员才能找到并加入此房间。", + "join_rule_knock_label": "任何人都可以申请加入,但管理员或协管员需要授予访问权限。你可以稍后更改此设置。", + "join_rule_public_label": "任何人都可以找到并加入此房间。", + "join_rule_public_parent_space_label": "任何人都可以找到并加入此房间,而不仅限于 中的成员。", + "join_rule_restricted": "标准", + "join_rule_restricted_label": "任何位于 中的人都可以加入。", + "name_validation_required": "为房间输入名称", + "room_visibility_label": "房间可见性", + "state_encrypted_warning": "启用加密状态事件的实验性支持,此功能会对服务器隐藏房间名称与主题等元数据。这些元数据对之后加入房间的用户与不支持 MSC4362 的客户端隐藏。", + "state_encryption_label": "加密状态事件", + "title_private_room": "创建私有房间", + "title_public_room": "创建公共房间", "title_video_room": "创建视频房间", - "topic_label": "话题(可选)", - "unfederated": "阻住任何不属于 %(serverName)s 的人加入此房间。", - "unfederated_label_default_off": "你可以启用此选项如果此房间将仅用于你的家服务器上的内部团队协作。此选项之后无法更改。", - "unfederated_label_default_on": "若房间将用于与拥有自己的家服务器的外部团队协作,则你可禁用此功能。这无法在以后更改。", + "topic_label": "主题(可选)", + "unfederated": "阻止任何不属于 %(serverName)s 的人员加入此房间。", + "unfederated_label_default_off": "如果房间仅用于与主服务器上的内部团队协作,则可以启用此项。一旦启用就无法更改。", + "unfederated_label_default_on": "如果此房间将用于与拥有自己主服务器的外部团队协作,你可以禁用此功能。此设置以后无法更改。", "unsupported_version": "服务器不支持指定的房间版本。" }, + "create_section_dialog": { + "create_section": "创建区域", + "description": "区域仅对你可见", + "label": "区域名称", + "title": "创建区域" + }, "create_space": { - "add_details_prompt": "添加一些细节,以便人们辨识你的社群。", - "add_details_prompt_2": "你随时可以更改它们。", - "add_existing_rooms_description": "选择要添加的房间或对话。这是专属于你的空间,不会有人被通知。你稍后可以再增加更多。", - "add_existing_rooms_heading": "你想要组织什么?", + "add_details_prompt": "添加一些信息以便人们识别。", + "add_details_prompt_2": "你可以随时更改。", + "add_existing_rooms_description": "选择要添加的房间或对话。这只是一个供你使用的空间,不会通知任何人。你可以稍后添加更多空间。", + "add_existing_rooms_heading": "你想组织哪些内容?", "address_label": "地址", "address_placeholder": "例如:my-space", - "done_action": "前往我的空间", - "done_action_first_room": "前往我的第一个房间", - "explainer": "空间是将房间和人分组的一种新方式。你想创建什么类型的空间?你可以在以后更改。", - "failed_create_initial_rooms": "创建初始空间房间失败", - "failed_invite_users": "邀请以下用户加入你的空间失败:%(csvUsers)s", - "invite_teammates_by_username": "按照用户名邀请", - "invite_teammates_description": "确保对的人可以访问。稍后你可以邀请更多人。", - "invite_teammates_heading": "邀请你的伙伴", + "creating": "正在创建…", + "creating_rooms": "正在创建房间…", + "done_action": "转到我的空间", + "done_action_first_room": "转到我的首个房间", + "explainer": "空间是发现并组织房间的一种方式。你想创建什么样的空间?", + "failed_create_initial_rooms": "创建初始空间失败", + "failed_invite_users": "邀请以下用户到空间失败:%(csvUsers)s", + "invite_teammates_by_username": "通过用户名邀请", + "invite_teammates_description": "确保认识的人可以访问。你可以稍后再邀请其他人。", + "invite_teammates_heading": "邀请同事", + "inviting_users": "正在邀请…", "label": "创建空间", "name_required": "请输入空间名称", - "personal_space": "仅有我", - "personal_space_description": "用于整理你房间的私有空间", - "private_description": "仅邀请,适合你自己或团队", + "personal_space": "只有我", + "personal_space_description": "一个用于组织你的房间的私有空间", + "private_description": "仅限邀请,适用于个人或团队", "private_heading": "你的私有空间", - "private_personal_description": "确保对的人有权访问 %(name)s", - "private_personal_heading": "你与谁一同工作?", - "private_space": "我和我的伙伴", - "private_space_description": "供你和你的伙伴使用的私有空间", - "public_description": "适合每一个人的开放空间,社群的理想选择", + "private_only_heading": "你的空间", + "private_personal_description": "确保受你期许的人员可以访问 %(name)s", + "private_personal_heading": "你与谁合作?", + "private_space": "我与我的同事", + "private_space_description": "你与同事的私有空间", + "public_description": "任何人都可以加入,适用于社区", "public_heading": "你的公共空间", - "setup_rooms_community_description": "让我们为每个主题都创建一个房间吧。", - "setup_rooms_community_heading": "你想在 %(spaceName)s 中讨论什么?", - "setup_rooms_description": "稍后你可以添加更多房间,包括现有的。", - "setup_rooms_private_heading": "你的团队正在进行什么项目?", - "share_description": "当前仅有你一人,与人同道而行会更好。", + "search_public_button": "在公共空间中搜索", + "setup_rooms_community_description": "创建一些房间以入门。", + "setup_rooms_community_heading": "你想在 %(spaceName)s 中讨论哪些内容?", + "setup_rooms_description": "你可以稍后添加更多房间到空间,包括现有房间。", + "setup_rooms_private_description": "创建一些房间以入门。", + "setup_rooms_private_heading": "你的团队正在进行哪些项目?", + "share_description": "目前只有你一人,如果有其他人,效果会更好。", "share_heading": "分享 %(name)s", "skip_action": "暂时跳过", - "subspace_beta_notice": "向你管理的空间添加空间。", + "subspace_adding": "正在添加…", + "subspace_beta_notice": "在受你管理的空间中添加子空间。", "subspace_dropdown_title": "创建空间", "subspace_existing_space_prompt": "想要添加现有空间?", - "subspace_join_rule_invite_description": "只有受邀者才能找到并加入此空间。", + "subspace_join_rule_invite_description": "只有受邀人员才能找到并加入此空间。", "subspace_join_rule_invite_only": "私有空间(仅邀请)", - "subspace_join_rule_label": "空间可见度", - "subspace_join_rule_public_description": "任何人都可以找到并加入这个空间,而不仅仅是 的成员。", - "subspace_join_rule_restricted_description": " 中的任何人都可以找到并加入。" + "subspace_join_rule_label": "空间可见性", + "subspace_join_rule_public_description": "任何人都可以找到并加入此空间,而不仅是 的成员。", + "subspace_join_rule_restricted_description": "位于 中的任何人都可以加入。" }, "credits": { - "default_cover_photo": "默认封面照片 ©Jesús Roncero 根据CC-BY-SA 4.0 条款使用。", - "twemoji": "Twemoji emoji art ©Twitter, Inc 和其他贡献者 根据CC-BY 4.0 条款使用。", - "twemoji_colr": "twemoji-colr 字体 ©Mozilla Foundation 根据Apache 2.0 条款使用。" + "default_cover_photo": "默认封面照片 © Jesús Roncero,根据 CC-BY-SA 4.0 的条款使用。", + "twemoji": "Twemoji Emoji 艺术 © Twitter, Inc 及其他贡献者 根据 CC-BY 4.0 的条款使用。", + "twemoji_colr": "twemoji-colr 样式 © Mozilla Foundation,根据 Apache 2.0 的条款使用。" + }, + "decline_invitation_dialog": { + "confirm": "你确定要拒绝 %(roomName)s 的加入邀请?", + "ignore_user_help": "你将看不到来自该用户的任何消息或房间邀请。", + "reason_description": "描述举报房间的理由。", + "report_room_description": "向账户提供者举报此房间。", + "title": "拒绝邀请" }, "desktop_default_device_name": "%(brand)s桌面版:%(platformName)s", "devtools": { - "active_widgets": "已启用的挂件", - "category_other": "其他", + "active_widgets": "激活小部件", + "category_other": "其它", "category_room": "房间", "caution_colon": "警告:", + "checking_sticky_events_support": "正在检查是否支持黏着事件…", "client_versions": "客户端版本", + "crypto": { + "4s_public_key_in_account_data": "在账户数据中", + "4s_public_key_not_in_account_data": "未找到", + "4s_public_key_status": "秘密存储公钥:", + "backup_key_cached": "本地缓存", + "backup_key_cached_status": "密钥缓存:", + "backup_key_not_stored": "未存储", + "backup_key_stored": "在秘密存储中", + "backup_key_stored_status": "密钥存储:", + "backup_key_unexpected_type": "意外类型", + "backup_key_well_formed": "良好", + "cross_signing": "交叉签名", + "cross_signing_cached": "本地缓存", + "cross_signing_not_ready": "交叉签名未设置", + "cross_signing_private_keys_in_storage": "在秘密存储中", + "cross_signing_private_keys_in_storage_status": "交叉签名私钥:", + "cross_signing_private_keys_not_in_storage": "存储中未找到", + "cross_signing_public_keys_on_device": "在内存中", + "cross_signing_public_keys_on_device_status": "交叉签名公钥:", + "cross_signing_ready": "交叉签名已就绪。", + "cross_signing_status": "交叉签名状态:", + "cross_signing_untrusted": "你的账户在秘密存储中拥有数字身份,但尚未被此会话信任。", + "crypto_not_available": "密码学模块不可用", + "device_id": "设备 ID", + "key_backup_active_version": "活跃的备份版本:", + "key_backup_active_version_none": "无", + "key_backup_inactive_warning": "你的密钥尚未在此会话中备份。", + "key_backup_latest_version": "服务器上的最新备份版本:", + "key_storage": "密钥存储", + "master_private_key_cached_status": "主私钥:", + "not_found": "未找到", + "not_found_locally": "本地未找到", + "secret_storage_not_ready": "未就绪", + "secret_storage_ready": "就绪", + "secret_storage_status": "秘密存储:", + "self_signing_private_key_cached_status": "自签名私钥:", + "session": "会话", + "session_fingerprint": "指纹(会话密钥)", + "title": "端到端加密", + "user_signing_private_key_cached_status": "用户签名私钥:" + }, "developer_mode": "开发者模式", "developer_tools": "开发者工具", + "device_dehydrated_no": "脱水:否", + "device_dehydrated_yes": "脱水:是", + "device_id": "设备 ID:%(deviceId)s", + "device_keys": "设备密钥", + "device_verification_status": { + "signed_by_owner": "验证状态: 经所有者签名", + "unknown": "验证状态:未知", + "unverified": "验证状态: 未经所有者签名", + "verified": "验证状态: 经交叉签名验证" + }, + "devices": "%(count)s 个密码学设备", "edit_setting": "编辑设置", "edit_values": "编辑值", "empty_string": "<空字符串>", + "error_sticky_duration_must_be_a_number": "stickyDuration 必须是数字", + "error_sticky_duration_out_of_range": "stickyDuration 取值范围必须在 0 ~ 36000 毫秒(1 小时)", "event_content": "事件内容", - "event_id": "事件ID:%(eventId)s", + "event_id": "事件 ID:%(eventId)s", "event_sent": "事件已发送!", "event_type": "事件类型", - "explore_room_state": "查找房间状态", - "failed_to_find_widget": "查找此挂件时出现错误。", + "expired": "已过期", + "expires_in": "剩余", + "explore_account_data": "浏览账户数据", + "explore_room_account_data": "浏览房间内账户数据", + "explore_room_state": "浏览房间状态", + "explore_sticky_state": "浏览黏着状态", + "failed_to_find_widget": "查找此小部件时出错。", "failed_to_load": "载入失败。", - "failed_to_save": "保存设置失败。", - "failed_to_send": "发送事件失败!", - "invalid_json": "看起来不像有效的JSON。", + "failed_to_save": "设置保存失败。", + "failed_to_send": "事件发送失败!", + "id": "ID:", + "invalid_device_key_id": "设备密钥 ID 无效", + "invalid_json": "JSON 似乎无效", "level": "层级", - "low_bandwidth_mode": "低带宽模式", - "low_bandwidth_mode_description": "需要兼容的家服务器。", - "number_of_users": "用户数", - "original_event_source": "原始事件源码", - "room_id": "房间ID: %(roomId)s", + "low_bandwidth_mode": "禁用对带宽有刚需的功能", + "low_bandwidth_mode_description": "禁用加密、状态、已读回执与键入通知", + "main_timeline": "主要时间线", + "manual_device_verification": "手动设备验证", + "no_receipt_found": "未找到回执", + "no_sticky_events": "此房间暂无黏着事件。", + "notification_state": "通知状态为 %(notificationState)s", + "notifications_debug": "通知调试", + "number_of_users": "用户数量", + "only_joined_members": "仅限已加入的用户", + "original_event_source": "原始事件源代码", + "restore_from_backup": "从备份恢复", + "room_encrypted": "房间已加密 ✅", + "room_id": "房间 ID:%(roomId)s", + "room_not_encrypted": "此房间未加密🚨", + "room_notifications_dot": "圆点:", + "room_notifications_highlight": "高亮:", + "room_notifications_last_event": "最新事件:", + "room_notifications_sender": "发送者:", + "room_notifications_thread_id": "消息列 ID:", + "room_notifications_total": "总数:", + "room_notifications_type": "类型:", + "room_status": "房间状态", + "room_unread_status_count": { + "one": "房间未读状态:%(status)s,数量:%(count)s", + "other": "房间未读状态:%(status)s,数量:%(count)s" + }, "save_setting_values": "保存设置值", + "see_history": "查看历史", "send_custom_account_data_event": "发送自定义账户数据事件", - "send_custom_room_account_data_event": "发送自定义房间账户资料事件", + "send_custom_room_account_data_event": "发送自定义房间账户数据事件", "send_custom_state_event": "发送自定义状态事件", + "send_custom_sticky_event": "发送自定义黏着事件", "send_custom_timeline_event": "发送自定义时间线事件", "server_info": "服务器信息", "server_versions": "服务器版本", - "settable_global": "全局可设置性", - "settable_room": "房间可设置性", + "settable_global": "全局可设置", + "settable_room": "可在房间内设置", "setting_colon": "设置:", "setting_definition": "设置定义:", "setting_id": "设置 ID", - "show_hidden_events": "显示时间线中的隐藏事件", + "settings": { + "elementCallUrl": "Element Call URL" + }, + "settings_explorer": "设置浏览器", + "show_empty_content_events": "以空白内容显示事件", + "show_hidden_events": "在时间线显示隐藏事件", "spaces": { - "one": "<空间>", - "other": "<%(count)s个空间>" + "one": "", + "other": "<%(count)s 个空间>" }, "state_key": "状态键(State Key)", + "sticky_duration": "黏着持续时间(毫秒)", + "sticky_events_not_supported": "你的主服务器不支持黏着事件。", + "thread_root_id": "消息列根 ID:%(threadRootId)s", + "threads_timeline": "消息列时间线", "title": "开发者工具", "toggle_event": "切换事件", "toolbox": "工具箱", - "use_at_own_risk": "此界面不会检查值的类型。使用风险自负。", + "use_at_own_risk": "此 UI 不检查值的数据类型。需自行承担使用风险。", + "user_avatar": "头像:%(avatar)s", + "user_displayname": "显示名称:%(displayname)s", + "user_id": "用户 ID:%(userId)s", + "user_no_avatar": "头像:", + "user_no_displayname": "显示名称:", + "user_read_up_to": "用户阅读到:", + "user_read_up_to_ignore_synthetic": "用户阅读到(ignoreSynthetic): ", + "user_read_up_to_private": "用户阅读到(m.read.private): ", + "user_read_up_to_private_ignore_synthetic": "用户阅读到(m.read.private;ignoreSynthetic):", + "user_room_membership": "成员资格", + "user_verification_status": { + "identity_changed": "验证状态: 未验证且已更改数字身份", + "unverified": "验证状态: 未验证", + "verified": "验证状态: 已验证", + "was_verified": "验证状态: 已验证但已更改数字身份" + }, + "users": "用户", "value": "值", "value_colon": "值:", - "value_in_this_room": "此房间中的值", + "value_in_this_room": "在此房间的值", "value_this_room_colon": "此房间中的值:", - "values_explicit": "各层级的值", - "values_explicit_colon": "各层级的值:", - "values_explicit_room": "此房间中各层级的值", - "values_explicit_this_room_colon": "此房间中各层级的值:", - "view_source_decrypted_event_source": "解密的事件源码", - "widget_screenshots": "对支持的挂件启用挂件截图" + "values_explicit": "显式值", + "values_explicit_colon": "显式值:", + "values_explicit_room": "此房间中的显式值", + "values_explicit_this_room_colon": "此房间中的显式值:", + "view_servers_in_room": "在房间中查看服务器", + "view_source_decrypted_event_source": "解密的事件源代码", + "view_source_decrypted_event_source_unavailable": "解密源代码不可用", + "widget_screenshots": "为支持的小部件启用“小部件屏幕截图”" }, "dialog_close_label": "关闭对话框", "download_completed": "下载完成", "emoji": { "categories": "类别", - "category_activities": "活动", - "category_animals_nature": "动物和自然", - "category_flags": "旗", - "category_food_drink": "食物和饮料", + "category_activities": "节假日", + "category_animals_nature": "动物与自然", + "category_flags": "旗帜", + "category_food_drink": "饮食", "category_frequently_used": "经常使用", - "category_objects": "物体", - "category_smileys_people": "表情和人", + "category_objects": "日常物品", + "category_smileys_people": "人与表情", "category_symbols": "符号", - "category_travel_places": "旅行和地点", + "category_travel_places": "文旅景点", "quick_reactions": "快速反应" }, "emoji_picker": { "cancel_search_label": "取消搜索" }, "empty_room": "空房间", - "empty_room_was_name": "空房间(曾是%(oldName)s)", + "empty_room_was_name": "空房间(曾是 %(oldName)s)", "encryption": { "access_secret_storage_dialog": { + "alternatives": "如果你有安全密钥或安全口令,这也会起作用。", "key_validation_text": { - "wrong_security_key": "安全密钥错误" + "wrong_security_key": "你输入的恢复密钥不正确。" }, + "privacy_warning": "确保此时无人窥视此屏幕!", "restoring": "从备份恢复密钥", - "security_key_title": "安全密钥" + "security_key_label": "恢复密钥", + "security_key_title": "输入恢复密钥" }, "bootstrap_title": "设置密钥", - "confirm_encryption_setup_body": "点击下方按钮以确认设置加密。", + "confirm_encryption_setup_body": "点击以下按钮确认加密设置。", "confirm_encryption_setup_title": "确认加密设置", - "cross_signing_room_normal": "此房间是端到端加密的", - "cross_signing_room_verified": "房间中所有人都已被验证", - "cross_signing_room_warning": "有人在使用未知会话", - "cross_signing_user_normal": "你没有验证此用户。", - "cross_signing_user_verified": "你验证了此用户。此用户已验证了其全部会话。", - "cross_signing_user_warning": "此用户没有验证其全部会话。", - "event_shield_reason_authenticity_not_guaranteed": "此加密消息的真实性无法在此设备上保证。", + "continue_with_reset": "继续重置", + "cross_signing_room_normal": "此房间已端到端加密", + "cross_signing_room_verified": "此房间中的每个成员都已验证", + "cross_signing_room_warning": "有人正在使用未知会话", + "cross_signing_user_normal": "你尚未验证此用户。", + "cross_signing_user_verified": "你已验证此用户。此用户已验证其所有会话。", + "cross_signing_user_warning": "该用户尚未验证其所有会话。", + "enter_recovery_key": "输入恢复密钥", + "event_shield_reason_authenticity_not_guaranteed": "此设备无法保证此加密消息的真实性。", + "event_shield_reason_mismatched_sender": "事件的发送者与发送该事件的设备的所有者不匹配。", "event_shield_reason_mismatched_sender_key": "由未验证的会话加密", + "event_shield_reason_unknown_device": "由未知或已被删除的设备加密。", + "event_shield_reason_unsigned_device": "由未经所有者验证的设备加密。", + "event_shield_reason_unverified_identity": "由未经验证的用户加密。", "export_unsupported": "你的浏览器不支持所需的密码学扩展", + "forgot_recovery_key": "忘记恢复密钥?", + "identity_needs_reset_description": "你必须重置数字身份才能确保访问消息历史", "import_invalid_keyfile": "不是有效的 %(brand)s 密钥文件", - "import_invalid_passphrase": "身份验证失败:密码错误?", + "import_invalid_passphrase": "身份验证检查失败:密码不正确?", + "key_storage_out_of_sync": "你的密钥存储不同步。", + "key_storage_out_of_sync_description": "请确认恢复密钥,以保持对密钥存储与消息历史记录的访问权。", + "message_shared_by": "由于此消息在发送时你不位于此房间,%(displayName)s(%(userId)s)共享了此消息。", "messages_not_secure": { - "cause_1": "你的家服务器", - "cause_2": "你正在验证的用户所连接的家服务器", - "cause_3": "你或其他用户的互联网连接", + "cause_1": "你的主服务器版本太旧,不支持所需的最低 API 版本。请联系你的服务器所有者或升级你的服务器。", + "cause_2": "你正在验证的用户所连接的主服务器", + "cause_3": "你或其他用户的 Internet 连接", "cause_4": "你或其他用户的会话", - "heading": "以下之一可能被损害:", - "title": "你的消息不安全" + "heading": "下列中的某项可能已泄露:", + "title": "你的消息传递不安全" }, "new_recovery_method_detected": { - "description_1": "检测到新的安全短语和安全消息密钥。", - "description_2": "此会话正在使用新的恢复方法加密历史。", - "title": "新恢复方式", - "warning": "如果你没有设置新恢复方式,可能有攻击者正试图侵入你的账户。请立即更改你的账户密码并在设置中设定一个新恢复方式。" + "description_1": "已检测到用于安全信息的新安全口令与密钥。", + "description_2": "此会话正在使用新的恢复方法加密历史记录。", + "title": "新的恢复方法", + "warning": "如果你未设置新的恢复方法,攻击者可能正在尝试访问你的账户。请立即在“设置”中更改你的账户密码并设置新的恢复方法。" }, + "pinned_identity_changed": "%(displayName)s (%(userId)s) 的数字身份已重置。了解更多", + "pinned_identity_changed_no_displayname": "%(userId)s的数字身份已重置。了解更多", "recovery_method_removed": { - "description_1": "此会话已检测到你的安全短语和安全消息密钥被移除。", - "description_2": "如果你出于意外这样做了,你可以在此会话上设置安全消息,以使用新的加密方式重新加密此会话的消息历史。", - "title": "恢复方式已移除", - "warning": "如果你没有移除此恢复方式,可能有攻击者正试图侵入你的账户。请立即更改你的账户密码并在设置中设定一个新的恢复方式。" + "description_1": "此会话检测到你的安全口令与安全消息密钥已被移除。", + "description_2": "如果不慎执行了此操作,你可以为此会话设置安全消息传递,它将使用新的恢复方法重新加密此会话的消息历史。", + "title": "恢复方法已移除", + "warning": "如果你未移除恢复方法,攻击者可能正在尝试访问你的账户。请立即在“设置”中更改你的账户密码并设置新的恢复方法。" }, + "set_up_recovery": "备份聊天", + "set_up_recovery_toast_description": "你的聊天已被端到端加密自动备份。如果你无法访问所有设备,则需要使用恢复密钥并保留数字身份。", "set_up_toast_title": "设置安全备份", "setup_secure_backup": { - "explainer": "在登出之前请备份密钥以免丢失。" + "explainer": "移除此设备前备份密钥以防止丢失。" }, + "turn_on_key_storage": "启用密钥存储", + "turn_on_key_storage_description": "这将允许你在新设备上查看聊天历史, 这是备份聊天与数字身份所必需的。", "udd": { - "interactive_verification_button": "用表情符号交互式验证", - "other_ask_verify_text": "要求此用户验证其会话,或在下面手动进行验证。", - "other_new_session_text": "%(name)s(%(userId)s)登录到未验证的新会话:", - "own_ask_verify_text": "使用以下选项之一验证你的其他会话。", - "own_new_session_text": "你登录了未经过验证的新会话:", - "title": "不可信任" + "interactive_verification_button": "使用 Emoji 交互式验证", + "other_ask_verify_text": "请该用户验证其会话,或在下面手动验证。", + "other_new_session_text": "%(name)s(%(userId)s)在未验证的情况下登录了一个新会话:", + "own_ask_verify_text": "请使用以下选项之一验证你的其它会话。", + "own_new_session_text": "你在未验证的情况下登录了新会话:", + "title": "未被信任" }, "unable_to_setup_keys_error": "无法设置密钥", "verification": { - "accepting": "正在接受……", + "accepting": "正在接受…", "after_new_login": { "device_verified": "设备已验证", "skip_verification": "暂时跳过验证", "verify_this_device": "验证此设备" }, - "cancelling": "正在取消……", - "complete_action": "收到", + "cancelled_verification": "请求超时、被拒绝或验证结果不匹配。", + "cancelling": "正在取消…", + "cant_confirm": "无法确认?", + "complete_action": "明白", "complete_description": "你已成功验证此用户。", "complete_title": "已验证!", - "explainer": "此用户的安全消息是端到端加密的,不能被第三方读取。", - "in_person": "为了安全,请当面完成或使用信任的方法交流。", - "incoming_sas_device_dialog_text_1": "验证此设备以将其标记为已信任。在收发端到端加密消息时,信任设备可让你与其他用户更加放心。", - "incoming_sas_device_dialog_text_2": "验证此设备会将其标记为已信任,与此同时,其他验证了你的用户也会信任此设备。", - "incoming_sas_dialog_title": "收到验证请求", - "incoming_sas_user_dialog_text_1": "验证此用户并将其标记为已信任。在收发端到端加密消息时,信任用户可让你更加放心。", - "incoming_sas_user_dialog_text_2": "验证此用户会将其会话标记为已信任,与此同时,你的会话也会被此用户标记为已信任。", - "no_support_qr_emoji": "你正在尝试验证的设备不支持扫码QR码或表情符号验证,这是%(brand)s所支持的。用不同的客户端试试。", - "other_party_cancelled": "另一方取消了验证。", - "prompt_encrypted": "验证房间中所有用户以确保其安全。", - "prompt_unencrypted": "在加密房间中,验证所有用户以确保其安全。", - "qr_or_sas": "%(qrCode)s或%(emojiCompare)s", + "confirm_identity_description": "验证此设备以设置安全消息传递", + "confirm_identity_title": "确认你的数字身份", + "confirm_the_emojis": "确认以下 Emoji 与你其它设备上显示的相符。", + "error_starting_description": "我们无法与其他用户开始聊天。", + "error_starting_title": "开始验证时出错", + "explainer": "与此用户的安全消息已进行端到端加密,第三方无法读取。", + "in_person": "为了安全起见,请亲自执行此操作或使用受信任的通信方式。", + "incoming_sas_device_dialog_text_1": "验证此设备并将其标记为受信任。信任此设备可让你和其它用户在使用端到端加密消息时更加安心。", + "incoming_sas_device_dialog_text_2": "验证此设备会将其标记为受信任,并且已与你验证过的用户将信任此设备。", + "incoming_sas_dialog_title": "验证请求传入", + "incoming_sas_dialog_waiting": "等待对方确认…", + "incoming_sas_user_dialog_text_1": "验证此用户并将其标记为受信任的用户。其可以让你在使用端到端加密消息时更加安心。", + "incoming_sas_user_dialog_text_2": "验证此用户会将他们的会话标记为受信任,也会将你的会话标记为对他们受信任。", + "manual": { + "already_verified": "此设备已经过验证", + "already_verified_and_wrong_fingerprint": "提供的指纹不匹配,但设备已验证!", + "device_id": "设备 ID", + "failure_description": "“%(deviceId)s”验证失败:%(error)s", + "failure_title": "验证失败", + "fingerprint": "指纹(会话密钥)", + "no_crypto": "无法验证设备:加密组件未启用", + "no_device": "无法验证设备:设备“%(deviceId)s”未找到", + "no_userid": "无法验证设备:无法找到用户 ID", + "success_description": "此设备(%(deviceId)s)已交叉签名", + "success_title": "验证成功", + "text": "请提供你自己的一台设备的 ID 和指纹进行验证。请注意,这将允许其它设备以你的身份发送和接收消息。如果有人告诉你在此处粘贴内容,你很可能被诈骗!", + "wrong_fingerprint": "无法验证设备“%(deviceId)s”:提供的指纹“%(fingerprint)s”与设备指纹“%(fprint)s”不匹配。" + }, + "no_support_qr_emoji": "你尝试验证的设备不支持扫描二维码或 Emoji 验证,而 %(brand)s 支持这些功能。请尝试使用其它客户端。", + "now_you_can": "现在你可以安全地读取或发送消息,并且与你聊天的任何人也可以信任此设备。", + "once_accepted_can_continue": "一旦被接受,你将可以继续验证。", + "other_party_cancelled": "对方已取消验证。", + "prompt_encrypted": "验证房间中的所有用户以确保安全。", + "prompt_unencrypted": "在加密房间中验证所有用户以确保安全。", + "qr_or_sas": "%(qrCode)s 或 %(emojiCompare)s", "qr_prompt": "扫描此唯一代码", - "qr_reciprocate_same_shield_user": "快完成了!%(displayName)s 显示了同样的盾牌吗?", + "qr_reciprocate_check_again_device": "再次在你的其它设备上检查以完成验证。", + "qr_reciprocate_no": "否,我没有看到绿色盾牌", + "qr_reciprocate_same_shield_user": "即将完成!%(displayName)s 显示的是相同的盾牌吗?", + "qr_reciprocate_yes": "是,我看到了绿色盾牌", + "request_toast_accept_user": "验证用户", + "request_toast_decline_counter": "忽略(剩余 %(counter)s 秒)", "request_toast_detail": "来自 %(ip)s 的 %(deviceId)s", - "sas_caption_self": "确认屏幕上出现以下数字,以验证设备。", - "sas_caption_user": "通过在其屏幕上显示以下数字来验证此用户。", - "sas_description": "若你在两个设备上都没有相机,比较唯一一组表情符号", - "sas_emoji_caption_user": "通过在其屏幕上显示以下表情符号来验证此用户。", - "sas_match": "它们匹配", - "sas_no_match": "它们不匹配", - "sas_prompt": "比较唯一表情符号", - "scan_qr": "扫码验证", - "scan_qr_explainer": "请 %(displayName)s 扫描你的代码:", + "request_toast_start_verification": "开始验证", + "sas_caption_self": "通过确认以下数字出现在设备屏幕上以验证此设备。", + "sas_caption_user": "通过确认以下数字出现在用户的屏幕上已验证此用户。", + "sas_description": "如果两台设备都没有摄像头,请比较一组唯一的 Emoji", + "sas_emoji_caption_user": "通过确认以下 Emoji 是否出现在对方的屏幕上从而验证此用户。", + "sas_match": "匹配", + "sas_no_match": "不匹配", + "sas_prompt": "比较唯一 Emoji", + "scan_qr": "通过扫描验证", + "scan_qr_explainer": "请求 %(displayName)s 扫描代码:", "start_button": "开始验证", - "successful_user": "你成功验证了 %(displayName)s!", - "unsupported_method": "无法找到支持的验证方法。", - "unverified_session_toast_title": "现在登录。请问是你本人吗?", - "unverified_sessions_toast_description": "检查以确保你的账户是安全的", - "unverified_sessions_toast_reject": "稍后再说", + "successful_user": "你已成功验证 %(displayName)s!", + "unsupported_method": "无法找到受支持的验证方法。", + "unverified_session_toast_accept": "是我", + "unverified_session_toast_title": "有新登录。是否为本人?", + "unverified_sessions_toast_description": "审阅以确保你的账户安全", + "unverified_sessions_toast_reject": "稍后", "unverified_sessions_toast_title": "你有未验证的会话", - "verification_dialog_title_device": "验证其他设备", + "use_another_device": "使用另一设备", + "use_recovery_key": "使用恢复密钥", + "verification_dialog_title_choose": "选择验证方式", + "verification_dialog_title_compare_emojis": "比较 Emoji", + "verification_dialog_title_confirm_green_shield": "确认你在其它设备上看到了绿色盾牌。", + "verification_dialog_title_device": "验证其它设备", + "verification_dialog_title_failed": "验证失败", + "verification_dialog_title_start_on_other_device": "在其它设备上开始验证", "verification_dialog_title_user": "验证请求", - "verification_skip_warning": "如果不进行验证,您将无法访问您的所有消息,并且在其他人看来可能不受信任。", - "verification_success_with_backup": "你的新设备已通过验证。它现在可以访问你的加密消息,并且其它用户会将其视为受信任的。", - "verification_success_without_backup": "你的新设备现已验证。其他用户将会视其为受信任的。", - "verify_emoji": "通过表情符号验证", - "verify_emoji_prompt": "通过比较唯一的表情符号来验证。", - "verify_emoji_prompt_qr": "如果你不能扫描以上代码,请通过比较唯一的表情符号来验证。", - "verify_later": "我稍后进行验证", - "waiting_for_user_accept": "正在等待%(displayName)s接受……", - "waiting_other_device": "正等待你在其它设备上验证……", - "waiting_other_device_details": "正等待你在其它设备上验证,%(deviceName)s(%(deviceId)s)……", - "waiting_other_user": "正在等待%(displayName)s进行验证……" + "verification_dialog_title_verified": "设备已验证", + "verification_skip_warning": "在没有验证的情况下你将无法访问所有消息,并且无法被其他人信任。", + "verification_success_with_backup": "你的新设备现已验证。它可以访问你的加密消息,其他用户将视其为受信任的设备。", + "verification_success_without_backup": "你的新设备已通过验证。其他用户将看到它是受信任的。", + "verify_by_completing_one_of": "完成以下任一方式进行验证:", + "verify_emoji": "使用 Emoji 验证", + "verify_emoji_prompt": "通过比较唯一的 Emoji 进行验证。", + "verify_emoji_prompt_qr": "如果无法扫描上述二维码,可通过比较唯一的 Emoji 验证。", + "verify_later": "稍后验证", + "waiting_for_user_accept": "正在等待 %(displayName)s 接受…", + "waiting_other_device": "正在等待其它设备验证…", + "waiting_other_device_details": "等待你在另一台设备上验证,%(deviceName)s(%(deviceId)s)…", + "waiting_other_user": "正在等待 %(displayName)s 验证…" }, "verification_requested_toast_title": "已请求验证", - "verify_toast_description": "其他用户可能不信任它", - "verify_toast_title": "验证此会话" + "verified_identity_changed": "%(displayName)s (%(userId)s) 的数字身份已重置。了解更多", + "verified_identity_changed_no_displayname": "%(userId)s的数字身份已重置。了解更多", + "verify_toast_description": "可能不受其他用户信任", + "verify_toast_title": "验证此设备", + "withdraw_verification_action": "撤消验证" }, "error": { - "admin_contact": "请 联系你的服务管理员 以继续使用本服务。", - "admin_contact_short": "请联系你的服务器管理员。", - "app_launch_unexpected_error": "准备软件时出现意外错误,详细信息请查看控制台。", - "cannot_load_config": "无法加载配置文件:请刷新页面以重试。", - "connection": "与家服务器通讯时出现问题,请稍后再试。", - "dialog_description_default": "发生了一个错误。", - "edit_history_unsupported": "你的家服务器似乎不支持此功能。", + "admin_contact": "请联系服务管理员以继续使用此服务。", + "admin_contact_short": "联系你的服务器管理员。", + "app_launch_unexpected_error": "准备 App 时发生意外错误。详情请查看控制台。", + "cannot_load_config": "无法加载配置文件:请刷新页面重试。", + "connection": "与主服务器通信时出现问题,请稍后重试。", + "dialog_description_default": "发生错误。", + "download_media": "下载源媒体失败,未找到源 URL", + "edit_history_unsupported": "你的主服务器似乎不支持此功能。", "failed_copy": "复制失败", - "hs_blocked": "此 homeserver 已被其管理员屏蔽。", - "invalid_configuration_mixed_server": "配置无效:无法与 default_server_name 或 default_server_config 一起指定 default_hs_url", - "invalid_configuration_no_server": "配置无效:没有指定默认服务器。", - "invalid_json": "Element 配置文件中包含无效的 JSON。请改正错误并重新加载页面。", + "hs_blocked": "此主服务器已被其管理员屏蔽。", + "invalid_configuration_mixed_server": "配置无效:“default_hs_url”不能与“default_server_name”或“default_server_config”同时指定。", + "invalid_configuration_no_server": "配置无效:未指定默认服务器。", + "invalid_json": "你的 Element 配置包含无效的 JSON。请修正此问题并重载页面。", "invalid_json_detail": "来自解析器的消息:%(message)s", - "invalid_json_generic": "无效的 JSON", - "mau": "此家服务器已达到其每月活跃用户限制。", + "invalid_json_generic": "无效 JSON", + "mau": "此主服务器已达到每月活跃用户数量限制。", "misconfigured": "Element 配置错误", - "mixed_content": "当浏览器地址栏里有 HTTPS 的 URL 时,不能使用 HTTP 连接家服务器。请使用 HTTPS 或者允许不安全的脚本。", - "non_urgent_echo_failure_toast": "你的服务器没有响应一些请求。", - "resource_limits": "本服务器已达到其使用量限制之一。", + "mixed_content": "当浏览器地址栏中显示 HTTPS 网址时,无法通过 HTTP 连接到主服务器。请使用 HTTPS 或启用不安全的脚本。", + "non_urgent_echo_failure_toast": "你的服务器不响应某些请求。", + "resource_limits": "该主服务器已超出其资源限制。", "session_restore": { - "clear_storage_button": "清除存储并登出", - "clear_storage_description": "登出并删除加密密钥?", - "description_1": "我们在尝试恢复你先前的会话时遇到了错误。", - "description_2": "如果你之前使用过较新版本的 %(brand)s,则你的会话可能与当前版本不兼容。请关闭此窗口并使用最新版本。", - "description_3": "清除本页储存在你浏览器上的数据或许能修复此问题,但也会导致你退出登录并无法读取任何已加密的聊天记录。", + "clear_storage_button": "移除此设备", + "clear_storage_description": "移除此设备的同时包括其加密密钥?", + "description_1": "尝试恢复之前的会话时出错。", + "description_2": "如果你之前使用过较新版本的 %(brand)s,你的会话可能与此版本不兼容。请关闭此窗口并返回到较新的版本。", + "description_3": "清除浏览器的存储空间或许可以解决问题,但会将此设备移除,并导致所有加密的聊天历史无法读取。", "title": "无法恢复会话" }, - "something_went_wrong": "出了点问题!", - "storage_evicted_description_1": "一些会话数据,包括加密消息密钥,已缺失。要修复此问题,登出并重新登录,然后从备份恢复密钥。", - "storage_evicted_description_2": "你的浏览器可能在磁盘空间不足时删除了此数据。", - "storage_evicted_title": "缺失会话数据", - "sync": "服务器连接失败,正在重试……", - "tls": "无法连接家服务器 - 请检查网络连接,确保你的家服务器 SSL 证书被信任,且没有浏览器插件拦截请求。", + "something_went_wrong": "出现问题!", + "storage_evicted_description_1": "包括加密消息密钥在内的部分会话数据丢失。请注销并重新登录以修复此问题,并从备份中恢复密钥。", + "storage_evicted_description_2": "你的浏览器可能在磁盘空间不足时移除了这些数据。", + "storage_evicted_title": "会话数据缺失", + "sync": "无法连接到主服务器,正在重试…", + "tls": "无法连接到主服务器,请检查网络连接,确保主服务器的 SSL 证书受信任,并且浏览器扩展程序未阻止请求。", "unknown": "未知错误", "unknown_error_code": "未知错误代码", - "update_power_level": "权力级别修改失败" + "update_history_visibility": "历史可见性更改失败", + "update_power_level": "权力值更改失败" }, - "error_app_opened_in_another_window": "%(brand)s已在另一个窗口中打开。单击“%(label)s”以在此处使用%(brand)s并断开其他窗口的连接。", - "error_database_closed_title": "数据库意外关闭", + "error_app_open_in_another_tab": "切换到其它标签页以连接到 %(brand)s。现在可以关闭此标签页。", + "error_app_open_in_another_tab_title": "%(brand)s 已在其它标签页中打开", + "error_app_opened_in_another_window": "%(brand)s 已在另一个窗口中打开。点击“%(label)s”即可在此处使用 %(brand)s 并断开与另一个窗口的连接。", + "error_database_closed_description": { + "for_desktop": "你的磁盘可能已满。请清理空间并重新加载。", + "for_web": "如果你清除了浏览数据,则预计会出现此消息。%(brand)s 可能也在另一个标签页中打开,或者你的磁盘已满。请清理一些空间并重载。" + }, + "error_database_closed_title": "%(brand)s 已停止工作", "error_dialog": { "copy_room_link_failed": { - "description": "无法将房间的链接复制到剪贴板。", + "description": "无法复制房间链接到剪贴板。", "title": "无法复制房间链接" }, - "error_loading_user_profile": "无法加载用户资料", - "forget_room_failed": "忘记房间失败,错误代码: %(errCode)s" + "error_loading_user_profile": "无法载入用户资料", + "forget_room_failed": "忘记房间 %(errCode)s 失败" }, "error_user_not_logged_in": "用户未登录", "event_preview": { "m.call.answer": { - "dm": "通话中", - "user": "%(senderName)s加入通话", - "you": "你加入通话" + "dm": "通话进行中", + "user": "%(senderName)s 已加入通话", + "you": "你已加入通话" }, "m.call.hangup": { "user": "%(senderName)s 结束了通话", "you": "你结束了通话" }, "m.call.invite": { - "dm_receive": "%(senderName)s正在通话", - "dm_send": "正在等待接听", - "user": "%(senderName)s开始了通话", + "dm_receive": "%(senderName)s 正在通话", + "dm_send": "等待接听", + "user": "%(senderName)s 开始了通话", "you": "你开始了通话" - } + }, + "m.emote": "* %(senderName)s %(emote)s", + "m.reaction": { + "user": "%(sender)s 使用 %(reaction)s 对 %(message)s 作出反应", + "you": "你使用了 %(reaction)s 对 %(message)s 作出反应" + }, + "m.sticker": "%(senderName)s:%(stickerName)s", + "m.text": "%(senderName)s:%(message)s", + "prefix": { + "audio": "音频", + "file": "文件", + "image": "图像", + "poll": "投票", + "video": "视频" + }, + "preview": "%(prefix)s:%(preview)s" }, "export_chat": { - "cancelled": "导出已取消", - "cancelled_detail": "成功取消了导出", - "confirm_stop": "您确定要停止导出数据吗?如果你这样做了,你需要重新开始。", - "creating_html": "正在创建 HTML...", - "creating_output": "正在创建输出...", + "cancelled": "已取消导出", + "cancelled_detail": "导出已被取消", + "confirm_stop": "你确定要停止导出数据?继而导致需要重新开始。", + "creating_html": "正在创建 HTML…", + "creating_output": "正在创建输出…", "creator_summary": "%(creatorName)s 创建了此房间。", "current_timeline": "当前时间线", - "enter_number_between_min_max": "输入一个 %(min)s 和 %(max)s 之间的数字", - "error_fetching_file": "获取文件出错", - "export_info": "这是 导出的开始。导出人 ,导出日期 %(exportDate)s。", - "export_successful": "成功导出!", + "enter_number_between_min_max": "输入 %(min)s 到 %(max)s 之间的数字", + "error_fetching_file": "获取文件时出错", + "export_info": "这是 导出的开始。由 导出于 %(exportDate)s。", + "export_successful": "导出成功!", "exported_n_events_in_time": { "one": "在 %(seconds)s 秒内导出了 %(count)s 个事件", "other": "在 %(seconds)s 秒内导出了 %(count)s 个事件" }, "exporting_your_data": "导出你的数据", "fetched_n_events": { - "one": "迄今获取了 %(count)s 事件", - "other": "迄今获取了 %(count)s 事件" + "one": "已获取距今 %(count)s 个事件", + "other": "已获取 %(count)s 个事件" }, "fetched_n_events_in_time": { - "one": "%(seconds)s 秒内获取了 %(count)s 个事件", - "other": "%(seconds)s 秒内获取了 %(count)s 个事件" + "one": "于 %(seconds)ss 内已获取 %(count)s 个事件", + "other": "于 %(seconds)ss 内已获取 %(count)s 个事件" }, "fetched_n_events_with_total": { - "one": "已获取总共 %(total)s 事件中的 %(count)s 个", - "other": "已获取 %(total)s 事件中的 %(count)s 个" + "one": "已获取 %(total)s 个事件中的 %(count)s 个", + "other": "已获取 %(total)s 个事件中的 %(count)s 个" }, - "fetching_events": "正在获取事件...", - "file_attached": "已附加文件", + "fetching_events": "正在获取事件…", + "file_attached": "附加文件", "format": "格式", "from_the_beginning": "从开头", - "generating_zip": "生成 ZIP", + "generating_zip": "生成 ZIP 压缩文件", "html": "HTML", - "html_title": "导出的数据", - "include_attachments": "包括附件", + "html_title": "已导出的数据", + "include_attachments": "包含附件", "json": "JSON", - "media_omitted": "省略了媒体文件", - "media_omitted_file_size": "省略了媒体文件 - 超出了文件大小限制", + "media_omitted": "媒体已被省略", + "media_omitted_file_size": "媒体已被省略,文件大小超过限制", "messages": "消息", "next_page": "下一组消息", - "num_messages": "消息数", - "num_messages_min_max": "消息数只能是一个介于 %(min)s 和 %(max)s 之间的整数", - "number_of_messages": "指定消息数", - "previous_page": "上一组信息", - "processing_event_n": "正在处理总共 %(total)s 事件中的事件 %(number)s", - "select_option": "从下面的选项中选择以从时间线导出聊天", + "num_messages": "消息数量", + "num_messages_min_max": "消息数量只能是 %(min)s 到 %(max)s 之间的数字", + "number_of_messages": "指定消息数量", + "previous_page": "上一组消息", + "processing": "正在处理…", + "processing_event_n": "处理 %(total)s 个事件中的 %(number)s 个", + "select_option": "选择以下选项以导出时间线中的聊天历史", "size_limit": "大小限制", - "size_limit_min_max": "大小只能是 %(min)sMB 和 %(max)sMB 之间的一个数字", - "starting_export": "正在导出...", - "successful": "成功导出", - "successful_detail": "导出成功了。你可以在下载文件夹中找到导出文件。", + "size_limit_min_max": "大小只能是介于 %(min)s MB 到 %(max)s MB 之间的数字", + "size_limit_postfix": "MB", + "starting_export": "正在开始导出…", + "successful": "导出成功", + "successful_detail": "导出成功。可以在“下载”文件夹中找到。", "text": "纯文本", "title": "导出聊天", - "topic": "话题:%(topic)s", - "unload_confirm": "你确定要在导出过程中退出吗?" + "topic": "主题:%(topic)s", + "unload_confirm": "你确定要在导出期间退出?" }, - "failed_load_async_component": "无法加载!请检查你的网络连接并重试。", + "failed_load_async_component": "无法加载!请检查网络连接并重试。", "feedback": { - "can_contact_label": "如果你有任何后续问题,可以联系我", - "comment_label": "备注", - "existing_issue_link": "请先查找一下 Github 上已有的问题,以免重复。找不到重复问题?发起一个吧。", - "may_contact_label": "如果您想跟进或让我测试即将到来的想法,您可以与我联系", - "platform_username": "我们将会记录你的平台及用户名,以帮助我们尽我们所能地使用你的反馈。", - "pro_type": "专业建议:如果你要发起新问题,请一并提交调试日志,以便我们找出问题根源。", + "can_contact_label": "后续有任何问题时可以联系我", + "comment_label": "评论", + "existing_issue_link": "请先查找 Github Issue 是否有与你同样的问题,如果不存在可以创建一个。", + "may_contact_label": "若要跟进或让我测试新推出的想法时可以联系我", + "platform_username": "我们会记录你的平台与用户名,以帮助我们尽可能多地利用你的反馈。", + "pro_type": "专业建议:若要开始提交 Bug,请同时附上调试日志以便我们确定问题的根源。", "send_feedback_action": "发送反馈", - "sent": "反馈已发送" + "sent": "反馈已发送!谢谢,我们已经收到!" }, "file_panel": { - "empty_description": "从聊天中附加文件或将文件拖放到房间的任何地方。", - "empty_heading": "此房间中没有文件可见", - "guest_note": "你必须 注册 以使用此功能", - "peek_note": "你必须加入房间以看到它的文件" + "empty_description": "在聊天中附加文件或要拖放文件到此房间的任意位置。", + "empty_heading": "此房间暂无可见的文件", + "guest_note": "你必须注册才能使用此功能", + "peek_note": "你必须加入此房间才能其中的文件" }, "forward": { - "filter_placeholder": "搜索房间或用户", + "filter_placeholder": "搜索房间或人员", "message_preview_heading": "消息预览", - "no_perms_title": "你无权执行此操作", + "no_perms_title": "你没有权限执行此操作。", "open_room": "打开房间", "send_label": "发送", "sending": "正在发送", - "sent": "已发送" + "sent": "发送" }, "identity_server": { "change": "更改身份服务器", - "change_prompt": "从身份服务器断开连接而连接到吗?", - "change_server_prompt": "如果你不想使用 以发现你认识的现存联系人并被其发现,请在下方输入另一个身份服务器。", - "checking": "检查服务器", - "description_connected": "你正在使用来发现你认识的现存联系人,同时也让他们可以发现你。你可以在下方更改你的身份服务器。", - "description_disconnected": "你现在没有使用身份服务器。若想发现你认识的现存联系人并被其发现,请在下方添加一个身份服务器。", - "description_optional": "使用身份服务器是可选的。如果你选择不使用身份服务器,你将不能被别的用户发现,也不能用邮箱或电话邀请别人。", - "disconnect": "断开身份服务器连接", - "disconnect_anyway": "仍然断开连接", - "disconnect_offline_warning": "断开连接前,你应从身份服务器删除你的个人数据。不幸的是,身份服务器当前处于离线状态或无法访问。", - "disconnect_personal_data_warning_1": "你仍然在身份服务器 共享你的个人数据。", - "disconnect_personal_data_warning_2": "我们推荐你在断开连接前从身份服务器上删除你的邮箱地址和电话号码。", - "disconnect_server": "从身份服务器 断开连接吗?", - "disconnect_warning": "断开身份服务器连接意味着你将无法被其他用户发现,同时你也将无法使用电子邮件或电话邀请别人。", + "change_prompt": "断开连接身份服务器 并连接到 ?", + "change_server_prompt": "如果你不想使用 来发现你认识的现有联系人,并使其无法被发现,请在下方输入另一个身份服务器。", + "changed": "身份服务器已更改", + "checking": "正在检查服务器", + "description_connected": "你目前正在使用 来发现你认识的现有联系人,并使其能够被你发现。你可以在下面更改你的身份服务器。", + "description_disconnected": "你尚未使用身份服务器。要发现联系人并使其可以被发现,请在下面添加。", + "description_optional": "使用身份服务器是可选的。如果你选择不使用身份服务器,其他用户将无法发现你,也无法通过邮件地址或电话号码邀请其他人。", + "disconnect": "断开身份服务器", + "disconnect_anyway": "强制断开", + "disconnect_offline_warning": "断开连接前你应该从身份服务器 移除个人数据。很遗憾,身份服务器 当前处于离线状态或无法访问。", + "disconnect_personal_data_warning_1": "你仍在身份服务器 分享个人数据。", + "disconnect_personal_data_warning_2": "我们建议你在断开连接之前,从身份服务器中删除邮件地址与电话号码。", + "disconnect_server": "断开身份服务器 ?", + "disconnect_warning": "断开连接身份服务器意味着其他用户将无法发现你,并且你将无法通过邮件地址或电话号码邀请其他人。", "do_not_use": "不使用身份服务器", - "error_connection": "无法连接到身份服务器", - "error_invalid": "身份服务器无效(状态码 %(code)s)", - "error_invalid_or_terms": "服务协议未同意或身份服务器无效。", - "no_terms": "你选择的身份服务器没有服务协议。", - "suggestions": "你应该:", - "suggestions_1": "检查你的浏览器是否安装有可能屏蔽身份服务器的插件(例如 Privacy Badger)", + "error_connection": "无法连接身份服务器", + "error_invalid": "不是有效的身份服务器(状态码 %(code)s)", + "error_invalid_or_terms": "未接受服务条款或身份服务器无效。", + "no_terms": "你选择的身份服务器没有任何服务条款。", + "suggestions": "你应:", + "suggestions_1": "检查你的浏览器扩展是否有任何可能阻止身份服务器(例如 Privacy Badger)。", "suggestions_2": "联系身份服务器 的管理员", - "suggestions_3": "等待并稍后重试", + "suggestions_3": "稍后再试", "url": "身份服务器(%(server)s)", - "url_field_label": "输入一个新的身份服务器", - "url_not_https": "身份服务器URL必须是HTTPS" + "url_field_label": "输入新的身份服务器", + "url_not_https": "身份服务器 URL 必须为 HTTPS" }, - "in_space": "在 %(spaceName)s 空间。", - "in_space1_and_space2": "在 %(space1Name)s 和 %(space2Name)s 空间。", + "in_space": "位于 %(spaceName)s。", + "in_space1_and_space2": "位于空间 %(space1Name)s 与 %(space2Name)s。", "in_space_and_n_other_spaces": { - "one": "在 %(spaceName)s 和其他 %(count)s 个空间。", - "other": "在 %(spaceName)s 和其他 %(count)s 个空间。" + "one": "%(spaceName)s 与剩余 1 个空间。", + "other": "位于 %(spaceName)s 与其余 %(count)s 个空间。" }, "incompatible_browser": { - "title": "不支持的浏览器" + "continue": "强制继续", + "description": "%(brand)s 使用了一些当前浏览器中不可用的浏览器功能。%(detail)s", + "detail_can_continue": "如果你继续操作,某些功能可能会停止运行,并且未来可能会丢失数据。", + "detail_no_continue": "如果你使用的不是最新版本,请尝试更新浏览器然后重试。", + "learn_more": "了解更多", + "linux": "Linux", + "macos": "Mac", + "supported_browsers": "为了获得最佳体验,请使用 ChromeFirefoxEdgeSafari。", + "title": "%(brand)s 不支持此浏览器", + "use_desktop_heading": "请改用 %(brand)s 桌面应用", + "use_mobile_heading": "在手机上换用 %(brand)s", + "use_mobile_heading_after_desktop": "或使用移动 App", + "windows_64bit": "Windows (64 位)", + "windows_arm_64bit": "Windows (ARM 64 位)" }, "info_tooltip_title": "信息", "integration_manager": { - "error_connecting": "此集成管理器为离线状态或者其不能访问你的家服务器。", - "error_connecting_heading": "不能连接到集成管理器", - "explainer": "集成管理器接收配置数据,并可以以你的名义修改挂件、发送房间邀请及设置权力级别。", + "connecting": "正在连接到集成管理器…", + "error_connecting": "集成管理器处于离线状态或其无法连接到你的主服务器。", + "error_connecting_heading": "无法连接到集成管理器", + "explainer": "集成管理器可接收配置数据, 并可以代表你修改小部件、发送房间邀请与设置权力值。", "manage_title": "管理集成", - "use_im": "使用集成管理器管理机器人、挂件和贴纸包。", - "use_im_default": "使用集成管理器(%(serverName)s)管理机器人、挂件和贴纸包。" + "toggle_label": "启用集成管理器", + "use_im": "使用集成管理器管理机器人、小部件与贴纸包。", + "use_im_default": "使用集成管理器 (%(serverName)s) 管理机器人、小部件与贴纸包。" }, "integrations": { - "disabled_dialog_title": "集成已禁用", - "impossible_dialog_description": "你的 %(brand)s 不允许你使用集成管理器来完成此操作,请联系管理员。", - "impossible_dialog_title": "集成未被允许" + "disabled_dialog_description": "在设置中启用“%(manageIntegrations)s”以执行此操作。", + "disabled_dialog_title": "集成已被禁用", + "impossible_dialog_description": "%(brand)s 不允许使用集成管理器执行此操作。请联系管理员。", + "impossible_dialog_title": "不允许集成" }, "invite": { - "email_caption": "通过邮箱邀请", - "email_use_default_is": "使用一个身份服务器以通过邮箱邀请。使用默认(%(defaultIdentityServerName)s)或在设置中管理。", - "email_use_is": "使用一个身份服务器以通过邮箱邀请。在设置中管理。", - "error_already_invited_room": "用户已被邀请至房间", - "error_already_invited_space": "用户已被邀请至空间", - "error_already_joined_room": "用户已在房间中", - "error_already_joined_space": "用户已在空间中", - "error_bad_state": "用户必须先解封才能被邀请。", - "error_dm": "我们无法创建你的私聊。", - "error_find_room": "尝试邀请用户时出错。", - "error_find_user_description": "下列用户可能不存在或无效,因此不能被邀请:%(csvNames)s", - "error_find_user_title": "寻找以下用户失败", - "error_invite": "我们不能邀请这些用户。请检查你想邀请的用户并重试。", - "error_permissions_room": "你没有权限将其他用户邀请至本房间。", - "error_permissions_space": "你无权邀请他人加入此空间。", - "error_profile_undisclosed": "用户可能存在页可能不存在", - "error_transfer_multiple_target": "通话只能转移到单个用户。", + "email_caption": "通过邮件邀请", + "email_limit_one": "通过邮件发送邀请一次只能发送一个", + "email_use_default_is": "使用身份服务器通过邮件地址邀请。使用默认的 (%(defaultIdentityServerName)s) 或在 设置 中进行管理。", + "email_use_is": "使用身份服务器通过邮件地址邀请。请在 “设置” 中进行管理。", + "error_already_invited_room": "用户已被邀请到房间", + "error_already_invited_space": "用户已被邀请到空间", + "error_already_joined_room": "用户已位于此房间", + "error_already_joined_space": "用户已位于此空间", + "error_bad_state": "该用户必须先被解封才能被邀请。", + "error_dm": "无法创建私聊。", + "error_find_room": "在邀请用户时出现问题。", + "error_find_user_description": "以下用户可能不存在或无效,因此无法被邀请:%(csvNames)s", + "error_find_user_title": "以下用户查找失败", + "error_invite": "我们无法邀请这些用户。请检查你要邀请的用户,然后重试。", + "error_permissions_room": "你无权邀请人员进入此房间。", + "error_permissions_space": "你无权邀请人员访问此空间。", + "error_profile_undisclosed": "用户既有可能存在,也有可能不存在", + "error_transfer_multiple_target": "通话只能转接到单个用户。", + "error_unfederated_room": "此房间为非联合房间。你不能从外部服务器邀请人员。", + "error_unfederated_space": "此空间未联合。你无法邀请人员到外部服务器。", "error_unknown": "未知服务器错误", "error_user_not_found": "用户不存在", - "error_version_unsupported_room": "用户的家服务器不支持此房间版本。", - "error_version_unsupported_space": "用户的家服务器版本不支持空间。", + "error_version_unsupported_room": "用户的主服务器不支持此房间的版本。", + "error_version_unsupported_space": "此主服务器不支持使用电话号码进行身份验证。", "failed_generic": "操作失败", "failed_title": "邀请失败", - "invalid_address": "无法识别地址", - "name_email_mxid_share_room": "使用名字、电子邮件地址、用户名(如)邀请某人或分享此房间。", - "name_email_mxid_share_space": "使用某人的名字、电子邮箱地址或用户名(如 )邀请他们,或分享此空间。", - "name_mxid_share_room": "使用某人的名字、用户名(如 )或分享此房间来邀请他们。", - "name_mxid_share_space": "使用某人的名字、用户名(如 )邀请他们,或分享此空间。", + "invalid_address": "未识别的地址", + "name_email_mxid_share_room": "使用对方的名称、邮件地址、用户名(例如 )或分享此房间。", + "name_email_mxid_share_space": "使用对方的名称、邮件地址、用户名(例如 )邀请,或分享此房间。", + "name_mxid_share_room": "请使用名称、用户名(例如 )邀请他人,或分享此房间。", + "name_mxid_share_space": "请使用名称、用户名(例如 )邀请他人,或分享此空间。", + "progress": { + "dont_close": "在完成前请勿退出 app。", + "preparing": "正在准备邀请…" + }, "recents_section": "最近对话", - "room_failed_partial": "我们已向其他人发送邀请,但无法邀请以下人员至", + "room_failed_partial": "我们已邀请其他人加入,但以下人员无法受邀加入 。", "room_failed_partial_title": "部分邀请无法发送", - "room_failed_title": "未能邀请用户加入 %(roomName)s", + "room_failed_title": "无法邀请用户到 %(roomName)s", "send_link_prompt": "或发送邀请链接", - "start_conversation_name_email_mxid_prompt": "使用某人的名称、电子邮箱地址或用户名来与其开始对话(如 )。", - "start_conversation_name_mxid_prompt": "使用某人的名字或用户名(如 )开始与其进行对话。", - "suggestions_disclaimer": "出于隐私考虑,部分建议可能会被隐藏。", - "suggestions_disclaimer_prompt": "如果您看不到您要找的人,请将您的邀请链接发送给他们。", - "suggestions_section": "最近私聊", - "to_room": "邀请至 %(roomName)s", - "to_space": "邀请至 %(spaceName)s", + "start_conversation_name_email_mxid_prompt": "要与某人开始对话,请使用其名称、邮件地址或用户名(例如 )。", + "start_conversation_name_mxid_prompt": "使用名称或用户名(例如 )与他人开始对话。", + "suggestions_disclaimer": "由于隐私原因,某些建议可能被隐藏。", + "suggestions_disclaimer_prompt": "如果你看不到要找的人员,请复制并向其发送以下邀请链接。", + "suggestions_section": "最近的私聊", + "to_room": "邀请到 %(roomName)s", + "to_space": "邀请到 %(spaceName)s", "transfer_dial_pad_tab": "拨号盘", - "transfer_user_directory_tab": "用户目录", - "unable_find_profiles_description_default": "找不到下列 Matrix ID 的用户资料,你还是要邀请吗?", - "unable_find_profiles_invite_label_default": "还是邀请", - "unable_find_profiles_invite_never_warn_label_default": "还是邀请,不用再提醒我", - "unable_find_profiles_title": "以下用户可能不存在" + "transfer_user_directory_tab": "用户名册", + "unable_find_profiles_description_default": "无法找到下列 Matrix ID 的个人资料,你是否仍然要邀请?", + "unable_find_profiles_invite_label_default": "强制邀请", + "unable_find_profiles_invite_never_warn_label_default": "强制邀请并不再警告", + "unable_find_profiles_title": "以下用户可能不存在", + "unban_first_title": "除非被解封否则用户无法被邀请" }, - "inviting_user1_and_user2": "正在邀请 %(user1)s 与 %(user2)s", + "inviting_user1_and_user2": "邀请 %(user1)s 与 %(user2)s", "inviting_user_and_n_others": { - "one": "正在邀请%(user)s和另外1个人", - "other": "正在邀请%(user)s和其他%(count)s人" + "one": "邀请 %(user)s 及剩余 1 个", + "other": "邀请了 %(user)s 与剩余 %(count)s 个" }, "items_and_n_others": { - "other": " 和其他 %(count)s 人", - "one": " 与另一个人" + "one": " 以及剩余 1 个", + "other": " 以及剩余 %(count)s 个" }, "keyboard": { - "activate_button": "激活选中的按钮", + "activate_button": "激活选择的按钮", + "alt": "Alt", "autocomplete_cancel": "取消自动补全", "autocomplete_force": "强制完成", - "autocomplete_navigate_next": "下个自动完成建议", - "autocomplete_navigate_prev": "上个自动完成建议", + "autocomplete_navigate_next": "下一个自动补全建议", + "autocomplete_navigate_prev": "上一个自动补全建议", "backspace": "", "cancel_reply": "取消回复消息", "category_autocomplete": "自动补全", @@ -1104,393 +1445,506 @@ "category_navigation": "导航", "category_room_list": "房间列表", "close_dialog_menu": "关闭对话框或上下文菜单", - "composer_jump_end": "跳至编辑器尾部", - "composer_jump_start": "跳至编辑器的开头", - "composer_navigate_next_history": "导航到编辑器历史里的下条消息", - "composer_navigate_prev_history": "导航到编辑器历史里的上条消息", - "composer_new_line": "换行", + "composer_jump_end": "跳转到编辑器末尾", + "composer_jump_start": "跳转到编辑器开头", + "composer_navigate_next_history": "导航到下一个消息编辑器历史", + "composer_navigate_prev_history": "导航到上一个消息编辑器历史", + "composer_new_line": "另起一行", "composer_redo": "重做编辑", "composer_toggle_bold": "切换粗体", "composer_toggle_code_block": "切换代码块", "composer_toggle_italics": "切换斜体", "composer_toggle_link": "切换链接", "composer_toggle_quote": "切换引用", - "composer_undo": "撤销编辑", + "composer_undo": "撤消编辑", + "control": "Ctrl", "dismiss_read_marker_and_jump_bottom": "忽略已读标记并跳转到底部", - "enter": "回车", - "go_home_view": "转到主视图", - "home": "主页", - "jump_first_message": "跳转至第一条消息", - "jump_last_message": "跳转至最后一条消息", + "end": "结束", + "enter": "", + "escape": "Esc", + "go_home_view": "转到主页视图", + "home": "", + "jump_first_message": "跳转到首个消息", + "jump_last_message": "跳转到最新消息", "jump_room_search": "跳转到房间搜索", "jump_to_read_marker": "跳转到最旧的未读消息", - "keyboard_shortcuts_tab": "打开此设置标签页", - "navigate_next_history": "下个最近访问过的房间或空间", - "navigate_next_message_edit": "导航到下条要编辑的消息", - "navigate_prev_history": "上个最近访问过的房间或空间", - "navigate_prev_message_edit": "导航到上条要编辑的消息", - "next_room": "下个房间或私聊", - "next_unread_room": "下个未读房间或私聊", + "keyboard_shortcuts_tab": "打开此设置页", + "navigate_next_history": "下一个最近访问的房间或空间", + "navigate_next_message_edit": "导航到下一条要编辑的消息", + "navigate_prev_history": "上一个最近访问的房间或空间", + "navigate_prev_message_edit": "导航到上一条要编辑的消息", + "next_landmark": "转到下一个焦点", + "next_room": "下一个房间或私聊", + "next_unread_room": "下一个未读房间或私聊", + "number": "[数字]", "open_user_settings": "打开用户设置", "page_down": "", "page_up": "", - "prev_room": "上个房间或私聊", - "prev_unread_room": "上个未读房间或私聊", - "room_list_collapse_section": "折叠房间列表段", - "room_list_expand_section": "展开房间列表段", - "room_list_navigate_down": "在房间列表中向下导航", - "room_list_navigate_up": "在房间列表中向上导航", - "room_list_select_room": "从房间列表选择房间", - "scroll_down_timeline": "在时间线里向下滚动", - "scroll_up_timeline": "在时间线里向上滚动", - "search": "搜索(必须启用)", + "prev_landmark": "转到上一个焦点", + "prev_room": "上一个房间或私聊", + "prev_unread_room": "上一个未读房间或私聊", + "room_list_collapse_section": "折叠房间列表部分", + "room_list_expand_section": "展开房间列表部分", + "room_list_navigate_down": "向下导航到房间列表", + "room_list_navigate_up": "向上导航到房间列表", + "room_list_select_room": "从房间列表中选择房间", + "save": "保存", + "scroll_down_timeline": "向下滚动时间线", + "scroll_up_timeline": "向上滚动时间线", + "search": "搜索(要使其生效必须启用相关功能)", "send_sticker": "发送贴纸", + "shift": "Shift", "space": "空格", - "switch_to_space": "按数字切换到空间", - "toggle_hidden_events": "切换隐藏事件可见性", + "switch_to_space": "使用数字切换空间", + "toggle_hidden_events": "切换隐藏事件的可见性", "toggle_microphone_mute": "切换麦克风静音", "toggle_right_panel": "切换右侧面板", - "toggle_space_panel": "切换空间仪表盘", - "toggle_top_left_menu": "切换左上方的菜单", - "toggle_webcam_mute": "切换网络相机开/关", + "toggle_space_panel": "切换空间面板", + "toggle_top_left_menu": "切换左上角菜单", + "toggle_webcam_mute": "切换摄像头开/关", "upload_file": "上传文件" }, "labs": { - "ask_to_join": "启用 “需要验证加入请求”", - "beta_description": "%(brand)s的下一步是什么?实验室是早期获得东西、测试新功能和在它们发布前帮助塑造的最好方式。", - "beta_feature": "这是beta功能", - "beta_feedback_leave_button": "要离开beta,请访问你的设置。", - "beta_feedback_title": "%(featureName)sBeta反馈", - "beta_section": "即将到来的功能", - "bridge_state": "在房间设置中显示桥接信息", + "ask_to_join": "启用申请加入", + "beta_description": "%(brand)s 的下一步是什么?实验室是提前获取信息、测试新功能并在其正式推出之前帮助其完善的最佳途径。", + "beta_feature": "此为 Beta 功能", + "beta_feedback_leave_button": "要退出 Beta 测试,请访问设置。", + "beta_feedback_title": "%(featureName)s Beta 功能反馈", + "beta_section": "即将推出的功能", + "bridge_state": "在房间设置中显示桥接器信息", "bridge_state_channel": "频道:", - "bridge_state_creator": "此桥曾由提供。", - "bridge_state_manager": "此桥接由 管理。", - "bridge_state_workspace": "工作空间:", - "click_for_info": "点击获取更多信息", - "currently_experimental": "目前是实验性的。", + "bridge_state_creator": "此桥接器由 提供。", + "bridge_state_manager": "此桥接器由 管理。", + "bridge_state_workspace": "工作区: ", + "click_for_info": "点击显示更多信息", + "currently_experimental": "当前为实验性。", "custom_themes": "支持添加自定义主题", - "element_call_video_rooms": "Element通话视频房间", - "experimental_description": "想要做点实验?试试我们开发中的最新点子。这些功能尚未确定;它们可能不稳定,可能会变动,也可能被完全丢弃。了解更多。", + "dynamic_room_predecessors": "动态房间前身", + "dynamic_room_predecessors_description": "需要启用 MSC3946 以支持“迟到房间归档”。", + "element_call_video_rooms": "Element Call 视频房间", + "encrypted_state_events": "已加密的状态事件", + "encrypted_state_events_description": "启用加密状态事件的实验性支持,此功能会对服务器隐藏房间名称与主题等元数据。这些元数据对之后加入房间的用户与不支持 MSC4362 的客户端隐藏。", + "exclude_insecure_devices": "发送或接收消息时排除不安全的设备", + "exclude_insecure_devices_description": "此模式启用后,加密消息将不会分享给未验证的设备,并且来自未验证设备的消息将显示为“错误”。注意:如果启用此模式,则可能无法与尚未验证其设备的用户通信。", + "experimental_description": "感觉很有实验性?试试我们正在开发的最新创意。这些功能尚未最终确定;它们可能不稳定,可能会改变,也可能被完全放弃。了解更多。", "experimental_section": "早期预览", - "feature_wysiwyg_composer_description": "在消息编辑器中使用富文本代替 Markdown。", - "group_calls": "新的群通话体验", + "extended_profiles_msc_support": "需要服务器支持 MSC4133", + "feature_disable_call_per_sender_encryption": "为 Element Call 禁用“按每个发送者加密”", + "feature_wysiwyg_composer_description": "在消息编辑器中使用富文本取代 Markdown。", + "group_calls": "新的群呼体验", "group_developer": "开发者", "group_encryption": "加密", "group_experimental": "实验性", "group_messaging": "消息传递", - "group_moderation": "审核", + "group_moderation": "管理", "group_profile": "个人资料", "group_rooms": "房间", "group_spaces": "空间", "group_themes": "主题", - "group_voip": "语音和视频", - "group_widgets": "挂件", - "hidebold": "隐藏通知的点标记(仅显示计数标记)", - "html_topic": "显示房间话题的HTML表现形式", - "join_beta": "加入beta", - "join_beta_reload": "加入beta会重载%(brand)s。", - "jump_to_date": "跳至日期(新增 /jumptodate 并跳至日期标头)", - "jump_to_date_msc_support": "需要您的服务器支持 MSC3030", - "latex_maths": "在消息中渲染LaTeX数学", - "leave_beta": "离开beta", - "leave_beta_reload": "离开beta会重载%(brand)s。", - "location_share_live": "实时位置共享", - "location_share_live_description": "临时的实现。位置在房间历史中持续保留。", - "mjolnir": "忽略他人的新方式", - "msc3531_hide_messages_pending_moderation": "让协管员隐藏等待审核的消息。", - "notification_settings": "通知设置", - "notification_settings_beta_caption": "引入一种更简单的方式来更改通知设置。以你喜欢的方式定制%(brand)s。", + "group_threads": "消息列", + "group_ui": "用户界面", + "group_voip": "语音与视频", + "group_widgets": "小部件", + "hidebold": "隐藏通知圆点(仅显示计数器徽章)", + "html_topic": "以 HTML 格式显示房间主题", + "join_beta": "参与 Beta 测试", + "join_beta_reload": "参与 Beta 测试将重载 %(brand)s。", + "jump_to_date": "跳转到日期(在编辑器中输入指令“/jumptodate”可以跳转到当天的消息开头)", + "jump_to_date_msc_support": "需要服务器支持 MSC3030", + "latex_maths": "在消息中渲染 LaTeX 数学公式", + "leave_beta": "退出 Beta 测试", + "leave_beta_reload": "退出 Beta 测试将重载 %(brand)s。", + "location_share_live": "分享实时位置", + "location_share_live_description": "临时实现。位置将持续保留于房间历史。", + "mjolnir": "忽略人员的新方式", + "msc3531_hide_messages_pending_moderation": "允许协管员隐藏等待审核的消息", + "new_room_list": "启用新的房间列表", + "notification_settings": "新的通知设置", + "notification_settings_beta_caption": "介绍一种更简单的方法来更改通知设置。就像你喜欢的那样定制 %(brand)s。", "notification_settings_beta_title": "通知设置", - "report_to_moderators": "报告给协管员", - "report_to_moderators_description": "在支持审核的房间中,“报告”按钮将让你向房间协管员举报滥用行为。", + "notifications": "在房间标题处启用通知面板", + "render_reaction_images": "在反应中渲染自定义图像", + "render_reaction_images_description": "有时被称为“自定义 Emoji”。", + "report_to_moderators": "向协管员举报", + "report_to_moderators_description": "在支持审核的房间中,“举报”按钮可以让你向房间协管员举报滥用行为。", + "room_list_sections": "房间列表区域", "sliding_sync": "滑动同步模式", - "sliding_sync_description": "正在积极开发中,不能禁用。", - "sliding_sync_server_no_support": "你的服务器缺少原生支持", - "under_active_development": "积极开发中。", + "sliding_sync_description": "正在处于开发中,并且一旦启用就无法禁用。当前与 Element Call 不兼容。", + "sliding_sync_disabled_notice": "重新登录以禁用", + "sliding_sync_server_no_support": "服务器缺少支持", + "under_active_development": "正在处于开发中。", + "unrealiable_e2e": "在加密房间中不可靠", "video_rooms": "视频房间", - "video_rooms_a_new_way_to_chat": "在 %(brand)s 中使用语音和视频的新方式。", - "video_rooms_always_on_voip_channels": "视频房间是嵌入在%(brand)s房间内的总是开启的VoIP频道。", - "video_rooms_beta": "视频房间是beta功能", - "video_rooms_faq1_answer": "使用左侧面板房间部分的“+”按钮。", - "video_rooms_faq1_question": "我如何创建视频房间?", - "video_rooms_faq2_answer": "是的,聊天时间线显示在视频旁。", - "video_rooms_faq2_question": "我能在视频通话的同时使用文字聊天吗?", + "video_rooms_a_new_way_to_chat": "在 %(brand)s 中语音与视频聊天的新方式。", + "video_rooms_always_on_voip_channels": "视频房间是嵌入到 %(brand)s 并始终在线的 VoIP 渠道。", + "video_rooms_beta": "视频房间是 Beta 功能", + "video_rooms_faq1_answer": "在左侧面板的“房间”部分点击“+”按钮。", + "video_rooms_faq1_question": "如何创建视频房间?", + "video_rooms_faq2_answer": "可以,时间线会显示在视频房间的一侧。", + "video_rooms_faq2_question": "我是否可以在视频房间中使用文字聊天?", + "video_rooms_feedbackSubheading": "感谢你试用 Beta 功能,请尽可能详细地描述以便我们改进它。", "wysiwyg_composer": "富文本编辑器" }, "labs_mjolnir": { - "advanced_warning": "⚠ 这些设置是为高级用户准备的。", - "ban_reason": "已忽略/已屏蔽", - "error_adding_ignore": "添加已忽略的用户/服务器时出现错误", + "advanced_warning": "⚠ 以下设置面向高级用户。", + "ban_reason": "已忽略或已屏蔽", + "error_adding_ignore": "添加忽略的用户/服务器时出错", "error_adding_list_description": "请验证房间 ID 或地址并重试。", - "error_adding_list_title": "订阅列表时出现错误", - "error_removing_ignore": "移除已忽略用户/服务器时出现错误", - "error_removing_list_description": "请重试或查看你的终端以获得提示。", - "error_removing_list_title": "取消订阅列表时出现错误", - "explainer_1": "在此处添加你想忽略的用户和服务器。使用星号以使%(brand)s匹配任何字符。例如,@bot:*会忽略全部在任何服务器上以“bot”为名的用户。", - "explainer_2": "忽略人是通过含有封禁规则的封禁列表来完成的。订阅一个封禁列表意味着被此列表阻止的用户/服务器将会对你隐藏。", - "lists": "你正在订阅:", - "lists_description_1": "订阅一个封禁列表会使你加入它!", - "lists_description_2": "如果这不是你想要的,请使用别的的工具来忽略用户。", - "lists_heading": "订阅的列表", - "lists_new_label": "封禁列表的房间 ID 或地址", + "error_adding_list_title": "订阅列表时出错", + "error_removing_ignore": "移除已忽略的用户/服务器时出错", + "error_removing_list_description": "请重试或查看控制台中的提示。", + "error_removing_list_title": "退订列表时出错", + "explainer_1": "在此处添加要忽略的用户与服务器。使用“*”可让 %(brand)s 匹配任何字符。例如 @bot:* 将忽略任何服务器上名称为“bot”的所有用户。", + "explainer_2": "忽略人员是通过“封禁列表”完成的,封禁列表包含谁被封禁的规则。订阅封禁列表意味着位于该列表的用户/服务器将对你隐藏。", + "lists": "你当前已订阅:", + "lists_description_1": "你订阅封禁列表意味着此列表将作为房间加入!", + "lists_description_2": "如果这不是你想要的,请使用其它工具忽略用户。", + "lists_heading": "已订阅的列表", + "lists_new_label": "封禁列表中的房间 ID 或地址", "no_lists": "你没有订阅任何列表", - "personal_empty": "你没有忽略任何人。", - "personal_heading": "个人封禁列表", - "personal_new_label": "要忽略的服务器或用户 ID", - "personal_new_placeholder": "例如: @bot:* 或 example.org", - "personal_section": "你正在忽略:", + "personal_description": "个性化封禁列表包含你不想看到其消息的所有用户/服务器。忽略第一个用户/服务器后,你的房间列表中将出现一个名为“%(myBanList)s”的新房间。请留在该房间确保封禁列表持续生效。", + "personal_empty": "你尚未忽略任何人。", + "personal_heading": "个性化封禁列表", + "personal_new_label": "要忽略的用户 ID 或服务器", + "personal_new_placeholder": "例如 @bot:* 或 example.org", + "personal_section": "你当前忽略:", "room_name": "我的封禁列表", - "room_topic": "这是你屏蔽的用户/服务器的列表——不要离开此房间!", + "room_topic": "此房间包含你已屏蔽的用户与服务器。要使封禁列表生效,请勿离开房间!", "rules_empty": "无", "rules_server": "服务器规则", - "rules_title": "封禁列表规则 - %(roomName)s", + "rules_title": "封禁列表规则:%(roomName)s", "rules_user": "用户规则", - "something_went_wrong": "出现问题。请重试或查看你的终端以获得提示。", + "something_went_wrong": "出现问题。请重试或查看控制台提示。", "title": "已忽略的用户", "view_rules": "查看规则" }, "language_dropdown_label": "语言下拉菜单", "leave_room_dialog": { - "last_person_warning": "你是这里唯一的人。如果你离开了,以后包括你在内任何人都将无法加入。", - "leave_room_question": "你确定要离开房间 “%(roomName)s” 吗?", - "leave_space_question": "你确定要离开空间「%(spaceName)s」吗?", - "room_rejoin_warning": "此房间不是公开房间。如果没有成员邀请,你将无法重新加入。", - "space_rejoin_warning": "此空间并不公开。在没有得到邀请的情况下,你将无法重新加入。" + "last_person_warning": "你是此处唯一的用户。如果你离开,以后将没有人能加入,包括你自己。", + "leave_room_question": "你确定要离开房间“%(roomName)s”?", + "leave_space_question": "你确定要离开空间“%(spaceName)s”?", + "room_leave_admin_warning": "你是此房间的唯一管理员。如果你离开,任何人都将无法更改房间设置或执行其它重要操作。", + "room_leave_mod_warning": "你是此房间的唯一管理员。如果你离开,任何人都将无法更改房间设置或执行其它重要操作。", + "room_rejoin_warning": "此房间非公共房间。你无法在未被邀请的情况下重新加入。", + "space_rejoin_warning": "此空间非公开。未经邀请,你将无法重新加入。" }, "left_panel": { "open_dial_pad": "打开拨号键盘" }, "lightbox": { "rotate_left": "向左旋转", - "rotate_right": "向右旋转" + "rotate_right": "向右旋转", + "title": "图像查看" }, "location_sharing": { - "MapStyleUrlNotConfigured": "此家服务器未配置显示地图。", - "MapStyleUrlNotReachable": "此家服务器未正确配置,故无法显示地图,亦或所配置的地图服务器无法使用。", - "click_drop_pin": "点击以放置图钉", - "click_move_pin": "点击以移动图钉", - "close_sidebar": "关闭侧边栏", + "MapStyleUrlNotConfigured": "此主服务器未配置地图显示。", + "MapStyleUrlNotReachable": "此主服务器的配置不正确,无法显示地图,或者配置的地图服务器可能无法访问。", + "WebGLNotEnabled": "显示地图需要 WebGL,请在浏览器设置中启用。", + "click_drop_pin": "点击以放置锚点", + "click_move_pin": "点击以移动锚点", + "close_sidebar": "关闭边栏", "error_fetch_location": "无法获取位置", - "error_no_perms_description": "你需要拥有正确的权限才能在此房间中共享位置。", - "error_no_perms_title": "你没有权限分享位置", - "error_send_description": "%(brand)s无法发送你的位置。请稍后再试。", + "error_no_perms_description": "你需要拥有合适的权限才能在此房间中分享位置。", + "error_no_perms_title": "你无权分享位置", + "error_send_description": "%(brand)s 无法发送你的位置。请稍后再试。", "error_send_title": "我们无法发送你的位置", "error_sharing_live_location": "分享实时位置时出错", "error_stopping_live_location": "停止实时位置时出错", "expand_map": "展开地图", - "failed_generic": "获取你的位置失败。请之后再试。", - "failed_load_map": "无法加载地图", - "failed_permission": "%(brand)s was denied permission to fetch your location. 请在你的浏览器中允许位置访问。", - "failed_timeout": "尝试获取你的位置超时。请之后再试。", - "failed_unknown": "获取位置时发生错误。请之后再试。", + "failed_generic": "无法获取你的位置。请稍后再试。", + "failed_load_map": "无法载入地图", + "failed_permission": "%(brand)s 被拒绝获取位置信息。请在浏览器设置中允许访问位置信息。", + "failed_timeout": "尝试获取位置超时。请稍后再试。", + "failed_unknown": "获取位置时出现未知错误。请稍后再试。", "find_my_location": "查找我的位置", - "live_description": "%(displayName)s的实时位置", - "live_enable_description": "请注意:这是使用临时实现的实验室功能。这意味着你无法删除你的位置历史,并且甚至在你停止与此房间分享实时位置后,高级用户将仍能查看你的位置历史。", - "live_enable_heading": "实时位置分享", - "live_location_active": "你正在分享你的实时位置", + "live_description": "%(displayName)s 的事实位置", + "live_enable_description": "请注意:这是一项使用临时实施方案的实验室功能。这意味着你将无法删除你的位置记录,即使你停止与此房间共享实时位置,高级用户仍然可以看到你的位置记录。", + "live_enable_heading": "分享实时位置", + "live_location_active": "你正在分享实时位置", "live_location_enabled": "实时位置已启用", "live_location_ended": "实时位置已结束", - "live_location_error": "实时位置错误", + "live_location_error": "实时位置出错", "live_locations_empty": "无实时位置", - "live_share_button": "分享%(duration)s", + "live_share_button": "分享 %(duration)s", "live_toggle_label": "启用实时位置分享", - "live_until": "实时分享直至%(expiryTime)s", + "live_until": "直播直到 %(expiryTime)s", + "live_update_time": "已更新 %(humanizedUpdateTime)s", + "loading_live_location": "正在加载实时位置…", "location_not_available": "位置不可用", "map_feedback": "地图反馈", "mapbox_logo": "Mapbox 图标", - "reset_bearing": "重置为向北方位", - "share_button": "共享位置", + "reset_bearing": "重置方向为正北", + "share_button": "分享位置", "share_type_live": "我的实时位置", - "share_type_own": "我当前的位置", - "share_type_pin": "放置图钉", - "share_type_prompt": "你想分享什么位置类型?", + "share_type_own": "我的当前位置", + "share_type_pin": "放置锚点", + "share_type_prompt": "你想分享的位置的类型?", "toggle_attribution": "切换属性" }, "member_list": { - "filter_placeholder": "过滤房间成员" + "count": { + "one": "%(count)s 位成员", + "other": "%(count)s 个成员" + }, + "filter_placeholder": "搜索房间内成员", + "invite_button_no_perms_tooltip": "你无权邀请用户", + "invited_label": "已邀请", + "list_title": "成员列表", + "no_matches": "不匹配" }, "member_list_back_action_label": "房间成员", - "message_edit_dialog_title": "消息编辑历史", + "message_edit_dialog_title": "消息编辑", + "migrating_crypto": "请稍候。我们正在更新 %(brand)s,以使加密更快、更可靠。", "mobile_guide": { - "toast_accept": "使用 app", - "toast_description": "在移动网页浏览器中 %(brand)s 是实验性功能。为了获取更好的体验和最新功能,请使用我们的免费原生应用。", - "toast_title": "使用 app 以获得更好的体验" + "toast_accept": "使用 App", + "toast_description": "%(brand)s 在移动 Web 浏览器上处于实验阶段。为了获得更好的体验和最新功能,请使用我们的免费原生应用。", + "toast_title": "使用 App 以获得更好的体验" }, - "name_and_id": "%(name)s%(userId)s", + "name_and_id": "%(name)s(%(userId)s)", "no_more_results": "没有更多结果", "notif_panel": { - "empty_description": "你没有可见的通知。", - "empty_heading": "一切完毕" + "empty_description": "你没有可见通知。", + "empty_heading": "你已阅读所有消息" }, "notifications": { "all_messages": "全部消息", - "all_messages_description": "获得每条消息的通知", + "all_messages_description": "接收每个消息的通知", "class_global": "全局", - "class_other": "其他", + "class_other": "其它", "default": "默认", - "enable_prompt_toast_description": "开启桌面通知", + "email_pusher_app_display_name": "邮件通知", + "enable_prompt_toast_description": "启用桌面通知", "enable_prompt_toast_title": "通知", "enable_prompt_toast_title_from_message_send": "不要错过任何回复", - "error_change_title": "修改通知设置", + "error_change_title": "更改通知设置", "keyword": "关键词", "keyword_new": "新的关键词", - "mark_all_read": "标记所有为已读", - "mentions_and_keywords": "@提及和关键词", - "mentions_and_keywords_description": "如设置中设定的那样仅通知提及和关键词", - "mentions_keywords": "提及&关键词", - "message_didnt_send": "消息没有发送。点击查看信息。", + "level_activity": "活动", + "level_highlight": "高亮", + "level_muted": "已静默", + "level_none": "无", + "level_notification": "通知", + "level_unsent": "未发送", + "mark_all_read": "全部设为已读", + "mentions_and_keywords": "提及与关键词", + "mentions_and_keywords_description": "仅按设置中的配置获取提及与关键词", + "mentions_keywords": "提及与关键词", + "message_didnt_send": "消息未发送。点击以获取信息。", "mute_description": "你不会收到任何通知" }, "notifier": { "m.key.verification.request": "%(name)s 正在请求验证" }, "onboarding": { - "create_room": "创建一个群聊", - "explore_rooms": "探索公共房间", - "has_avatar_label": "很好,这样大家就知道是你了", - "intro_byline": "拥有您的对话。", - "intro_welcome": "欢迎来到 %(appName)s", - "no_avatar_label": "添加照片,让人们知道这是你。", + "create_room": "创建群聊", + "explore_rooms": "浏览公共房间", + "has_avatar_label": "很好,这将有助于人们确认是你", + "intro_byline": "掌控你的对话。", + "intro_welcome": "欢迎使用 %(appName)s", + "no_avatar_label": "添加照片让别人知道这是你。", "send_dm": "发送私聊", - "welcome_detail": "现在,让我们协助你开始", + "welcome_detail": "协助你开始", "welcome_user": "欢迎 %(name)s" }, + "pill": { + "permalink_other_room": "一个位于 %(room)s 的消息", + "permalink_this_room": "来自 %(user)s 的消息" + }, "poll": { "create_poll_action": "创建投票", "create_poll_title": "创建投票", - "disclosed_notes": "投票者一投完票就能看到结果", + "disclosed_notes": "结果将在参与者投票后立即可见", "edit_poll_title": "编辑投票", - "end_description": "您确定要结束此投票吗? 这将显示投票的最终结果并阻止人们投票。", - "end_message": "投票已经结束。 得票最多答案:%(topAnswer)s", - "end_message_no_votes": "投票已经结束。 没有投票。", + "end_description": "你确定要结束此投票?此操作将显示最终结果并阻止人员投票。", + "end_message": "投票已结束。热门答案:%(topAnswer)s", + "end_message_no_votes": "投票已结束。无人投票。", "end_title": "结束投票", - "error_ending_description": "抱歉,投票没有结束。 请再试一次。", + "ended_poll_label": "投票已结束", + "error_ending_description": "抱歉,投票尚未结束。请重试。", "error_ending_title": "结束投票失败", - "error_voting_description": "抱歉,你的投票未登记。请重试。", - "error_voting_title": "投票未登记", - "failed_send_poll_description": "抱歉,您尝试创建的投票未被发布。", + "error_voting_description": "抱歉,你的投票尚未注册。请重试。", + "error_voting_title": "投票未注册", + "failed_send_poll_description": "抱歉,你尝试创建的投票未能发布。", "failed_send_poll_title": "发布投票失败", - "notes": "结果仅在你结束投票后展示", + "notes": "结果仅在结束投票时显示", + "option_label": "选项 %(number)s,%(answer)s", + "option_label_winning_with_total": { + "one": "选项 %(number),%(answer)s,领先,%(count)s 票", + "other": "选项 %(number),%(answer)s,领先,%(count)s 票" + }, + "option_label_with_total": { + "one": "选项 %(number),%(answer)s,%(count)s 票", + "other": "选项 %(number),%(answer)s,%(count)s 票" + }, "options_add_button": "添加选项", "options_heading": "创建选项", "options_label": "选项 %(number)s", - "options_placeholder": "写个选项", - "topic_heading": "你的投票问题或主题是什么?", - "topic_label": "问题或主题", + "options_placeholder": "撰写选项", + "poll_label": "投票", + "topic_heading": "投票中的提问或主题?", + "topic_label": "提问或主题", + "topic_placeholder": "撰写内容…", + "total_decryption_errors": "由于解密错误可能无法统计某些投票", "total_n_votes": { - "one": "票数已达 %(count)s 票。要查看结果请亲自投票", - "other": "票数已达 %(count)s 票。要查看结果请亲自投票" + "one": "已有 %(count)s 个投票。投票以查看结果", + "other": "已有 %(count)s 个投票。投票以查看结果" }, "total_n_votes_voted": { - "one": "基于 %(count)s 票", - "other": "基于 %(count)s 票" + "one": "基于 %(count)s 个投票", + "other": "基于 %(count)s 个投票" }, - "total_no_votes": "尚无投票", - "total_not_ended": "结果将在投票结束时可见", + "total_no_votes": "暂无人投票", + "total_not_ended": "结果将于投票结束后可见。", "type_closed": "封闭式投票", "type_heading": "投票类型", "type_open": "开放式投票", - "unable_edit_description": "抱歉,你无法在有人投票后编辑投票。", + "unable_edit_description": "抱歉,已有投票的情况下你无法再编辑投票。", "unable_edit_title": "无法编辑投票" }, "power_level": { "admin": "管理员", + "creator": "所有者", "custom": "自定义(%(level)s)", - "custom_level": "自定义级别", + "custom_level": "自定义权力值", "default": "默认", - "label": "权力级别", + "label": "权力值", "moderator": "协管员", - "restricted": "受限" + "restricted": "已受限" }, - "powered_by_matrix": "由 Matrix 驱动", - "powered_by_matrix_with_logo": "去中心化、加密的聊天与协作,由 $matrixLogo 驱动", + "powered_by_matrix": "由 Matrix 提供底层支持", "presence": { "away": "离开", - "busy": "忙", + "busy": "忙碌", "idle": "空闲", - "idle_for": "已闲置 %(duration)s", + "idle_for": "已空闲 %(duration)s", "offline": "离线", - "offline_for": "已离线 %(duration)s", + "offline_for": "离线 %(duration)s", "online": "在线", - "online_for": "已上线 %(duration)s", - "unknown": "未知的", - "unknown_for": "未知状态已持续 %(duration)s" + "online_for": "已在线 %(duration)s", + "unknown": "未知", + "unknown_for": "未知 %(duration)s", + "unreachable": "无法访问用户的服务器" }, "quick_settings": { "all_settings": "所有设置", - "metaspace_section": "固定到侧边栏", + "metaspace_section": "钉在边栏", "sidebar_settings": "更多选项", "title": "快速设置" }, "quit_warning": { - "call_in_progress": "你似乎正在通话,确定要退出吗?", - "file_upload_in_progress": "你似乎正在上传文件,确定要退出吗?" + "call_in_progress": "你似乎正在通话,你确定要退出?", + "file_upload_in_progress": "你似乎正在上传文件,你确定要退出?" }, "redact": { "confirm_button": "确认移除", - "error": "你无法删除这条消息。(%(code)s)", + "confirm_description": "你确定要移除(删除)此事件?", + "confirm_description_state": "请注意,如此移除房间更改可能会使更改失效。", + "error": "你无法删除此消息。(%(code)s)", "ongoing": "正在移除…", "reason_label": "理由(可选)" }, "report_content": { - "description": "举报此消息会将其唯一的“事件ID”发送给你的家服务器的管理员。如果此房间中的消息是加密的,则你的家服务器管理员将无法阅读消息文本,也无法查看任何文件或图片。", + "description": "举报此消息会将其唯一的“事件 ID”发送给服务器管理员。如果此房间中的消息已加密,则服务器管理员将无法查看消息文本、任何文件或图像。", "disagree": "不同意", - "hide_messages_from_user": "若想隐藏来自此用户的全部当前和未来的消息,请打勾。", + "error_create_room_moderation_bot": "无法使用协管机器人创建房间", + "hide_messages_from_user": "如果你想隐藏此用户当前及未来的所有消息,请选中此项。", "ignore_user": "忽略用户", - "illegal_content": "违法内容", - "missing_reason": "请填写你为何做此报告。", - "nature": "请选择性质并描述为什么此消息是滥用。", - "nature_disagreement": "此用户所写的是错误内容。\n这将会报告给房间协管员。", - "nature_illegal": "此用户正在做出违法行为,如对他人施暴,或威胁使用暴力。\n这将报告给房间协管员,他们可能会将其报告给执法部门。", - "nature_nonstandard_admin": "该房间专用于非法或不良内容,或者版主未能对非法或不良内容进行管理。\n这将报告给%(homeserver)s 的管理员。", - "nature_nonstandard_admin_encrypted": "该房间专用于非法或不良内容,或者版主未能对非法或不良内容进行管理。\n这将报告给 %(homeserver)s 的管理员。管理员无法读取此房间的加密内容。", - "nature_other": "任何其他原因。请描述问题。\n这将报告给房间协管员。", - "nature_spam": "此用户正在房间中滥发广告、广告链接或宣传。\n这将报告给房间协管员。", - "other_label": "其他", - "report_content_to_homeserver": "向你的家服务器管理员举报内容", - "report_entire_room": "报告整个房间", - "spam_or_propaganda": "垃圾信息或宣传", - "toxic_behaviour": "不良行为" + "illegal_content": "非法内容", + "missing_reason": "请填写举报理由。", + "nature": "请选择一种性质,并描述该消息为何具有滥用性。", + "nature_disagreement": "此用户输入的内容有误。\n此问题将报告给房间协管员。", + "nature_illegal": "此用户存在违法行为,例如人肉搜索或威胁使用暴力。\n我们会将此情况报告给房间管理员,管理员可能会将此问题上报给法律部门。", + "nature_nonstandard_admin": "此房间专门用于非法或有害内容,或者管理员未能审核非法或有害内容。\n此问题将报告给 %(homeserver)s 的管理员。", + "nature_nonstandard_admin_encrypted": "此房间专门发布非法或有害内容,或者管理员未能审核非法或有害内容。\n此问题将报告给 %(homeserver)s 的管理员。管理员将无法阅读此房间的加密内容。", + "nature_other": "任何其它原因。请描述问题。\n这将报告给房间管理员。", + "nature_spam": "此用户正在向房间发送垃圾广告、广告链接或推广内容。\n我们将向房间管理员报告此问题。", + "nature_toxic": "此用户表现不良,例如辱骂其他用户、在家庭友好型房间分享仅限成人的内容或以其它方式违反此房间的规则。\n将向房间管理员举报。", + "other_label": "其它", + "report_content_to_homeserver": "向服务器管理员举报此内容", + "report_entire_room": "举报整个房间", + "spam_or_propaganda": "垃圾内容或广告", + "toxic_behaviour": "有害行为" + }, + "report_room": { + "description": "向服务器管理员举报此房间。如果消息已被加密,则管理员无法看到它们。", + "reason_label": "描述理由" }, "restore_key_backup_dialog": { "count_of_decryption_failures": "%(failedCount)s 个会话解密失败!", - "count_of_successfully_restored_keys": "成功恢复了 %(sessionCount)s 个密钥", - "enter_key_description": "通过输入你的安全密钥来访问你的安全消息历史记录并设置安全通信。", - "enter_key_title": "输入安全密钥", - "enter_phrase_description": "无法通过你的安全短语访问你的安全消息历史记录并设置安全通信。", - "enter_phrase_title": "输入安全短语", - "incorrect_security_phrase_dialog": "无法使用此安全短语解密备份:请确认你是否输入了正确的安全短语。", - "incorrect_security_phrase_title": "安全短语错误", - "key_backup_warning": "警告:你应此只在受信任的电脑上设置密钥备份。", - "key_forgotten_text": "如果你忘记了你的安全密钥,你可以", - "key_is_invalid": "安全密钥无效", - "key_is_valid": "看起来是有效的安全密钥!", - "keys_restored_title": "已恢复密钥", - "load_error_content": "无法获取备份状态", - "load_keys_progress": "%(total)s 个密钥中之 %(completed)s 个已恢复", - "no_backup_error": "找不到备份!", - "phrase_forgotten_text": "如果你忘记了你的安全短语,你可以使用你的安全密钥设置新的恢复选项", - "recovery_key_mismatch_description": "无法使用此安全密钥解密备份:请检查你输入的安全密钥是否正确。", - "recovery_key_mismatch_title": "安全密钥不符", - "restore_failed_error": "无法还原备份" + "count_of_successfully_restored_keys": "成功恢复 %(sessionCount)s 个密钥", + "enter_key_description": "访问你的安全消息历史记录并通过输入恢复密钥来设置安全消息传递。", + "enter_key_title": "输入恢复密钥", + "enter_phrase_description": "访问你的安全消息历史记录,并通过输入你的安全口令来设置安全消息传递。", + "enter_phrase_title": "输入安全口令", + "incorrect_security_phrase_dialog": "无法使用此安全口令解密备份:请确认你输入的安全口令是否正确。", + "incorrect_security_phrase_title": "安全口令不正确", + "key_backup_warning": "警告:你应仅从受信任的计算机设置密钥备份。", + "key_fetch_in_progress": "从服务器获取密钥", + "key_forgotten_text": "如果你忘记了恢复密钥,你可以。", + "key_is_invalid": "非有效的恢复密钥", + "key_is_valid": "似乎是有效的恢复密钥!", + "keys_restored_title": "密钥已恢复", + "load_error_content": "无法载入备份状态", + "load_keys_progress": "已恢复 %(total)s 个密钥中的 %(completed)s 个", + "no_backup_error": "未找到备份!", + "phrase_forgotten_text": "如果你忘记了安全口令,你可以使用恢复密钥设置新的恢复选项。", + "recovery_key_mismatch_description": "无法使用此恢复密钥解密备份:请确认输入了正确的恢复密钥。", + "recovery_key_mismatch_title": "恢复密钥不正确", + "restore_failed_error": "无法恢复备份" }, "right_panel": { - "add_integrations": "添加挂件、桥接和机器人", + "add_integrations": "添加扩展", + "add_topic": "添加主题", + "extensions_button": "扩展", + "extensions_empty_description": "选择“%(addIntegrations)s”以浏览并添加扩展到此房间。", + "extensions_empty_title": "使用更多工具、小部件与机器人提高生产力", "files_button": "文件", "pinned_messages": { + "empty_description": "选择一个消息并点击“%(pinAction)s”以包含在此处。", + "empty_title": "置顶重要的消息以便于发现", + "header": { + "one": "1 个已置顶的消息", + "other": "%(count)s 个已置顶的消息", + "zero": "已置顶的消息" + }, "limits": { - "other": "你仅能固定 %(count)s 个挂件" - } + "other": "你最多只能钉住 %(count)s 个小部件" + }, + "menu": "打开菜单", + "reply_thread": "回复 消息列中的消息", + "unpin_all": { + "button": "取消置顶所有消息", + "content": "确实要移除所有已置顶的消息?此操作无法撤消。", + "title": "取消置顶所有消息?" + }, + "view": "在时间线中查看" }, - "pinned_messages_button": "已固定", + "pinned_messages_button": "已置顶的消息", "poll": { + "active_heading": "进行中的投票", + "empty_active": "此房间暂无进行中的投票", + "empty_active_load_more": "暂无进行中的投票。加载更多投票以查看过去几个月的投票。", + "empty_active_load_more_n_days": { + "one": "过去 1 天暂无进行中的投票。载入更多投票以获取过去几个月的投票", + "other": "过去 %(count)s 天暂无进行中的投票。载入更多投票以获取过去几个月的投票" + }, + "empty_past": "此房间暂无过往投票", + "empty_past_load_more": "暂无过往投票。载入更多投票以获取过去几个月的投票", + "empty_past_load_more_n_days": { + "one": "过去 1 天没有过往投票。加载更多投票以查看过去几个月的投票。", + "other": "过去 %(count)s 天暂无过往的投票。载入更多投票以获取过去几个月的投票" + }, "final_result": { - "one": "基于 %(count)s 票数的最终结果", - "other": "基于 %(count)s 票数的最终结果" - } + "one": "基于 %(count)s 个投票的最终结果", + "other": "基于 %(count)s 个投票的最终结果" + }, + "load_more": "载入更多投票", + "loading": "正在载入投票", + "past_heading": "过往的投票", + "view_in_timeline": "在时间线中查看投票", + "view_poll": "查看投票" }, - "polls_button": "投票历史", + "polls_button": "投票", "room_summary_card": { "title": "房间信息" }, @@ -1502,745 +1956,1099 @@ } }, "room": { - "3pid_invite_email_not_found_account": "该邀请被发送到了与你的账户无关的 %(email)s", - "3pid_invite_email_not_found_account_room": "这个到 %(roomName)s 的邀请是发送给 %(email)s 的,而此邮箱没有关联你的账户", - "3pid_invite_error_description": "尝试验证你的邀请时返回错误(%(errcode)s)。你可以尝试把这个信息传给邀请你的人。", - "3pid_invite_error_invite_action": "仍然尝试加入", - "3pid_invite_error_invite_subtitle": "你只能通过有效邀请加入。", - "3pid_invite_error_public_subtitle": "你依旧可以加入这里。", - "3pid_invite_error_title": "你的邀请出了问题。", - "3pid_invite_error_title_room": "你到 %(roomName)s 的邀请出错", - "3pid_invite_no_is_subtitle": "要直接在 %(brand)s 中接收邀请,请在设置中使用一个身份服务器。", - "banned_by": "你被 %(memberName)s 封禁", - "banned_from_room_by": "你被 %(memberName)s 从 %(roomName)s 封禁了", + "3pid_invite_email_not_found_account": "此邀请已发送到 %(email)s,但该邮箱地址与你的账户无关。", + "3pid_invite_email_not_found_account_room": "%(roomName)s 的邀请已发送到 %(email)s,该邮箱与你的账户无关。", + "3pid_invite_error_description": "尝试验证邀请时出错(%(errcode)s)。你可以尝试将此信息转发给邀请你的人员。", + "3pid_invite_error_invite_action": "尝试强制加入", + "3pid_invite_error_invite_subtitle": "你只能使用有效的邀请加入。", + "3pid_invite_error_public_subtitle": "你仍然可以加入。", + "3pid_invite_error_title": "你的邀请出现问题。", + "3pid_invite_error_title_room": "你对 %(roomName)s 的邀请出现问题", + "3pid_invite_no_is_subtitle": "使用“设置”中的身份服务器,可直接在 %(brand)s 中接收邀请。", + "banned_by": "你已被 %(memberName)s 封禁", + "banned_from_room_by": "你已被 %(memberName)s 禁止进入 %(roomName)s", "context_menu": { "copy_link": "复制房间链接", "favourite": "收藏", "forget": "忘记房间", "low_priority": "低优先级", + "mark_read": "设为已读", + "mark_unread": "设为未读", + "notifications_default": "跟随系统设置", + "notifications_mute": "静默房间", "title": "房间选项", "unfavourite": "已收藏" }, - "creating_room_text": "正在创建房间%(names)s", + "creating_room_text": "正在以 %(names)s 为名称创建房间", "dm_invite_action": "开始聊天", - "dm_invite_subtitle": " 想聊天", - "dm_invite_title": "你想和 %(user)s 聊天吗?", - "drop_file_prompt": "把文件拖到这里以上传", - "edit_topic": "编辑话题", - "error_join_404_invite": "邀请你的人已经离开了,亦或是他们的家服务器离线了。", - "error_join_404_invite_same_hs": "邀请你的人已经离开了。", - "error_join_connection": "加入时发生错误。", - "error_join_incompatible_version_1": "抱歉,你的家服务器过旧,故无法参与其中。", - "error_join_incompatible_version_2": "请 联系你的家服务器管理员。", + "dm_invite_subtitle": " 想要聊天", + "dm_invite_title": "你想与 %(user)s 聊天吗?", + "drop_file_prompt": "拖放文件到此处以上传", + "edit_topic": "编辑主题", + "error_cancel_knock_title": "取消失败", + "error_join_403": "你需要被邀请才能访问此房间。", + "error_join_404_1": "你尝试使用房间 ID 加入,但未提供要加入的服务器列表。房间 ID 是内部标识符,如果没有其它信息,则无法用于加入房间。", + "error_join_404_2": "如果你知道房间地址,请尝试通过该地址加入。", + "error_join_404_invite": "邀请你的人已离开,或其服务器已离线。", + "error_join_404_invite_same_hs": "邀请你的人已离开。", + "error_join_connection": "加入时出错。", + "error_join_incompatible_version_1": "抱歉,你的主服务器太旧,无法在此处参与。", + "error_join_incompatible_version_2": "请联系主服务器管理员。", "error_join_title": "加入失败", - "error_jump_to_date_connection": "在尝试查找并跳转到给定日期时发生网络错误。你的服务器可能出现了故障,或者你的网络暂时出现了问题。请再试一次。如果依然发生这种情况,请联系您的服务器管理员", + "error_join_unknown": "发生未知错误。", + "error_jump_to_date": "服务器返回 %(statusCode)s,错误代码为 %(errorCode)s", + "error_jump_to_date_connection": "尝试查找并跳转到指定日期时出现网络错误。主服务器可能已关闭,或互联网连接只是暂时出现问题。请重试。如果此问题持续存在,请联系主服务器管理员。", + "error_jump_to_date_details": "错误详细信息", + "error_jump_to_date_not_found": "我们找不到从 %(dateString)s 开始的后续活动。请尝试选择更早的日期。", + "error_jump_to_date_send_logs_prompt": "请提交调试日志以帮助我们追踪问题。", + "error_jump_to_date_title": "无法找到该日期的事件", "face_pile_summary": { - "one": "已有你所认识的 %(count)s 个人加入", - "other": "已有你所认识的 %(count)s 个人加入" + "one": "你认识的 %(count)s 个人已加入", + "other": "%(count)s 个你认识的人已加入" }, "face_pile_tooltip_label": { - "one": "查看 1 位成员", - "other": "查看全部 %(count)s 位成员" + "one": "查看 1 个成员", + "other": "查看全部 %(count)s 个成员" }, "face_pile_tooltip_shortcut": "包括 %(commaSeparatedMembers)s", "face_pile_tooltip_shortcut_joined": "包括你,%(commaSeparatedMembers)s", - "failed_reject_invite": "拒绝邀请失败", + "failed_determine_user": "由于成员事件已更改,无法确定要忽略哪个用户。", + "failed_reject_invite": "邀请拒绝失败", "forget_room": "忘记此房间", "forget_space": "忘记此空间", "header": { - "room_is_public": "此房间为公共的" + "join_video_call": "加入视频通话", + "join_voice_call": "加入语音通话", + "n_people_asking_to_join": { + "one": "申请加入", + "other": "%(count)s 个人员申请加入" + }, + "room_is_public": "此为公共房间", + "shared_history_tooltip": "新成员可以看到历史", + "world_readable_history_tooltip": "任何人都可以看到历史" }, - "inaccessible": "这个房间或空间当前不可访问。", - "inaccessible_name": "%(roomName)s 此时无法访问。", - "inaccessible_subtitle_1": "等一会儿再试或联系管理员检查你是否拥有访问权限。", - "inaccessible_subtitle_2": "尝试访问房间或空间时返回%(errcode)s。若你认为你看到这条消息是有问题的,请提交bug报告。", + "header_avatar_open_settings_label": "打开房间设置", + "header_face_pile_tooltip": "人员", + "header_untrusted_label": "不受信任", + "inaccessible": "此房间或空间暂时无法访问。", + "inaccessible_name": "目前无法访问 %(roomName)s。", + "inaccessible_subtitle_1": "请稍后重试,或联系房间或空间管理员检查你是否有权访问。", + "inaccessible_subtitle_2": "尝试访问房间或空间时返回了 %(errcode)s。如果认为此消息有误,请提交 Bug 报告。", "intro": { - "dm_caption": "除非你们其中一个邀请了别人加入,否则将仅有你们两个人在此对话中。", + "display_topic": "主题:", + "dm_caption": "除非两者之一邀请他人加入,否则此处只有你们两个人的对话。", + "edit_topic": "主题:编辑)", "enable_encryption_prompt": "在设置中启用加密。", - "no_avatar_label": "添加图片,让人们一眼就能看到你的房间。", - "no_topic": "添加话题,让大家知道这里是讨论什么的。", - "private_unencrypted_warning": "你的私人消息通常是加密的,但此房间不是。这通常是因为使用了不受支持的设备或方法,例如电子邮件邀请。", - "room_invite": "仅邀请至此房间", - "send_message_start_dm": "发送你的第一条消息邀请来聊天", - "start_of_dm_history": "这是你与的私聊历史的开端。", - "start_of_room": "这里是 的开始。", - "unencrypted_warning": "未启用端到端加密", + "encrypted_3pid_dm_pending_join": "一旦所有人都加入,你就可以开始聊天", + "no_avatar_label": "添加一张照片,这样别人就能很容易地找到你的房间。", + "no_topic": "添加主题有助于人们了解此房间与哪些事物相关。", + "private_unencrypted_warning": "你的私有消息通常是加密的,但此房间不是。通常这是由于使用了不受支持的设备或方法,例如通过邮件地址邀请。", + "room_invite": "仅邀请到此房间", + "send_message_start_dm": "发送第一个消息以邀请 聊天", + "start_of_dm_history": "此处是你与 的私聊历史的开头。", + "start_of_room": "这是 的开始。", + "unencrypted_warning": "端到端加密未启用", "user_created": "%(displayName)s 创建了此房间。", "you_created": "你创建了此房间。" }, - "invite_email_mismatch_suggestion": "要在 %(brand)s 中直接接收邀请,请在设置中共享此邮箱。", - "invite_sent_to_email": "邀请已被发送到 %(email)s", - "invite_sent_to_email_room": "这个到 %(roomName)s 的邀请是发送给 %(email)s 的", - "invite_subtitle": " 邀请了你", + "invite_email_mismatch_suggestion": "在“设置 ”中分享此邮件,以便直接在 %(brand)s 中接收邀请。", + "invite_sent_to_email": "此邀请已发送至 %(email)s", + "invite_sent_to_email_room": "到 %(roomName)s 的邀请已发送到 %(email)s", + "invite_subtitle": "由 邀请", "invite_this_room": "邀请到此房间", "invite_title": "你想加入 %(roomName)s 吗?", - "inviter_unknown": "未知的", + "inviter_unknown": "未知", "invites_you_text": " 邀请了你", "join_button_account": "注册", - "join_failed_needs_invite": "你需要一个邀请来查看 %(roomName)s", + "join_failed_needs_invite": "要查看 %(roomName)s,你需要被邀请", "join_the_discussion": "加入讨论", "join_title": "加入房间以参与", - "join_title_account": "使用一个账户加入对话", - "joining": "加入中…", - "jump_read_marker": "跳到第一条未读消息。", + "join_title_account": "通过账户加入对话", + "joining": "正在加入…", + "joining_room": "正在加入房间…", + "joining_space": "加入空间", + "jump_read_marker": "跳转到首个未读消息。", "jump_to_bottom_button": "滚动到最近的消息", "kick_reason": "原因:%(reason)s", - "kicked_by": "%(memberName)s 将你移出了这里", - "kicked_from_room_by": "%(memberName)s 将你移出了 %(roomName)s", + "kicked_by": "你已被 %(memberName)s 移除", + "kicked_from_room_by": "你已被 %(memberName)s 从 %(roomName)s 移除", + "knock_cancel_action": "取消请求", + "knock_denied_subtitle": "由于你被拒绝访问,除非收到群组管理员或协管员的邀请,否则你无法重新加入。", + "knock_denied_title": "你已被拒绝访问", + "knock_message_field_placeholder": "消息(可选)", + "knock_prompt": "申请加入?", + "knock_prompt_name": "申请加入 %(roomName)s?", + "knock_send_action": "请求访问", + "knock_sent": "已发送加入申请", + "knock_sent_subtitle": "你的加入请求正在等待处理。", + "knock_subtitle": "你需要获得此房间的访问权限才能查看或参与对话。你可以在下方发送加入申请。", "leave_error_title": "离开房间时出错", - "leave_server_notices_description": "此房间是用于发布来自家服务器的重要讯息的,所以你不能退出它。", - "leave_server_notices_title": "无法退出服务器公告房间", - "leave_unexpected_error": "试图离开房间时发生意外服务器错误", - "link_email_to_receive_3pid_invite": "要在 %(brand)s 中直接接收邀请,请在设置中将你的账户连接到此邮箱。", - "loading_preview": "加载预览中", - "no_peek_join_prompt": "%(roomName)s 不能被预览。你想加入吗?", - "no_peek_no_name_join_prompt": "这里没有预览, 你是否要加入?", - "not_found_subtitle": "你确定你位于正确的地方?", - "not_found_title": "这个房间或空间不存在。", - "not_found_title_name": "%(roomName)s 不存在。", - "peek_join_prompt": "你正在预览 %(roomName)s。想加入吗?", - "read_topic": "点击阅读话题", - "rejoin_button": "重新加入", - "unread_notifications_predecessor": { - "other": "你在此房间的先前版本中有 %(count)s 条未读通知。", - "one": "你在此房间的先前版本中有 %(count)s 条未读通知。" + "leave_server_notices_description": "此房间用于存储来自主服务器的重要消息,因此你无法离开。", + "leave_server_notices_title": "无法离开“服务器通知”房间", + "leave_unexpected_error": "尝试离开房间时出现意外服务器错误", + "link_email_to_receive_3pid_invite": "请在“设置”中将此邮件地址与你的账户关联,以便直接在 %(brand)s 中接收邀请。", + "loading_preview": "正在载入预览", + "no_peek_join_prompt": "%(roomName)s 无法预览。是否加入?", + "no_peek_no_name_join_prompt": "暂无预览,是否加入?", + "not_found_subtitle": "你确定来对地方了吗?", + "not_found_title": "此房间或空间不存在。", + "not_found_title_name": "%(roomName)s 不存在。 ", + "peek_join_prompt": "你正在预览 %(roomName)s。是否加入?", + "pinned_message_banner": { + "button_close_list": "关闭列表", + "button_view_all": "查看全部", + "description": "此房间包含已置顶的消息。点击查看。", + "go_to_newest_message": "在此处查看时间线中的置顶消息和最新的置顶消息", + "go_to_next_message": "在此处查看时间线中的置顶消息和下一条最早的置顶消息", + "title": "第 %(index)s 个已置顶的消息,共 %(length)s 个" }, - "upgrade_error_description": "请再次检查你的服务器是否支持所选房间版本,然后再试一次。", - "upgrade_error_title": "升级房间时发生错误", - "upgrade_warning_bar": "升级此房间将会关闭房间的当前实例并创建一个具有相同名称的升级版房间。", - "upgrade_warning_bar_admins": "此警告仅房间管理员可见", - "upgrade_warning_bar_unstable": "此房间运行的房间版本是 ,此版本已被家服务器标记为 不稳定 。", - "upgrade_warning_bar_upgraded": "此房间已经被升级。", + "read_topic": "点击阅读主题", + "rejecting": "正在拒绝邀请…", + "rejoin_button": "重新加入", + "room_content": "房间内容", + "room_is_low_priority": "此为低优先级房间", + "search": { + "all_rooms_button": "搜索所有房间", + "placeholder": "搜索消息…", + "summary": { + "one": "为“”找到 1 个结果", + "other": "根据关键词“”找到 %(count)s 个结果" + }, + "this_room_button": "搜索此房间" + }, + "unknown_status_code_for_timeline_jump": "未知状态码", + "unread_notifications_predecessor": { + "one": "你的 %(count)s 个未读通知在此房间的上一版本中。", + "other": "你在此房间的上一版本中有 %(count)s 个未读通知。" + }, + "upgrade_error_description": "请仔细检查你的服务器是否支持所选的房间版本,然后重试。", + "upgrade_error_title": "升级房间时出错", + "upgrade_warning_bar": "升级此房间将关闭当前的房间实例,并创建一个同名的已升级房间。", + "upgrade_warning_bar_admins": "仅房间管理员可以看到此警告", + "upgrade_warning_bar_unstable": "此房间正在运行房间版本 ,此主服务器已将其标记为不稳定。", + "upgrade_warning_bar_upgraded": "此房间已升级过。", "upload": { "uploading_multiple_file": { - "other": "正在上传 %(filename)s 与其他 %(count)s 个文件", - "one": "正在上传 %(filename)s 与其他 %(count)s 个文件" + "one": "正在上传 %(filename)s 与剩余 %(count)s 个文件", + "other": "正在上传 %(filename)s 与剩余 %(count)s 个文件" }, "uploading_single_file": "正在上传 %(filename)s" - } + }, + "video_room": "此房间为视频房间", + "waiting_for_join_subtitle": "受邀用户加入 %(brand)s 后,你将能够聊天,并且房间将端到端加密。", + "waiting_for_join_title": "等待用户加入 %(brand)s" }, "room_list": { "add_room_label": "添加房间", "add_space_label": "添加空间", - "breadcrumbs_empty": "没有最近访问过的房间", + "breadcrumbs_empty": "暂无最近访问的房间", "breadcrumbs_label": "最近访问的房间", - "failed_add_tag": "无法为房间新增标签 %(tagName)s", - "failed_remove_tag": "移除房间标签 %(tagName)s 失败", - "failed_set_dm_tag": "设置私聊标签失败", + "failed_add_tag": "为房间添加标签 %(tagName)s 失败", + "failed_remove_tag": "从房间移除标签 %(tagName)s 失败", + "failed_set_dm_tag": "私聊标签设置失败", "home_menu_label": "主页选项", "join_public_room_label": "加入公共房间", "joining_rooms_status": { - "one": "目前正在加入 %(count)s 个房间", - "other": "目前正在加入 %(count)s 个房间" + "one": "当前正在加入 %(count)s 个房间", + "other": "当前加入了 %(count)s 个房间" + }, + "list_title": "房间列表", + "more_options": { + "leave_room": "离开房间" }, "notification_options": "通知选项", "redacting_messages_status": { - "one": "目前正在移除%(count)s个房间中的消息", - "other": "目前正在移除%(count)s个房间中的消息" + "one": "正在移除房间中的 %(count)s 个消息", + "other": "正在移除房间中的 %(count)s 个消息" + }, + "section": { + "chats": "聊天", + "favourites": "收藏", + "low_priority": "低优先级" }, "show_less": "显示更少", "show_n_more": { - "other": "多显示 %(count)s 个", - "one": "多显示 %(count)s 个" + "one": "显示剩余 %(count)s 个", + "other": "显示剩余 %(count)s 个" }, "show_previews": "显示消息预览", "sort_by": "排序", "sort_by_activity": "活动", "sort_by_alphabet": "字典顺序", - "sort_unread_first": "优先显示有未读消息的房间", - "space_menu_label": "%(spaceName)s菜单", + "sort_unread_first": "优先显示包含未读消息的房间", + "space_menu_label": "%(spaceName)s 菜单", "sublist_options": "列表选项", "suggested_rooms_heading": "建议的房间" }, "room_settings": { "access": { - "description_space": "决定谁可以查看和加入 %(spaceName)s。", + "description_space": "决定谁可以查看并加入 %(spaceName)s。", "title": "访问" }, "advanced": { - "error_upgrade_description": "房间可能没有完整地升级", + "error_upgrade_description": "无法完成升级此房间", "error_upgrade_title": "房间升级失败", "information_section_room": "房间信息", "information_section_space": "空间信息", - "room_id": "内部房间ID", - "room_predecessor": "查看%(roomName)s里更旧的消息。", - "room_upgrade_button": "升级此房间至推荐版本", + "room_id": "内部房间 ID", + "room_predecessor": "在 %(roomName)s 中查看更早的消息", + "room_upgrade_button": "将此房间升级到推荐的房间版本", + "room_upgrade_warning": "警告:升级房间不会自动将房间成员迁移到新版本房间。我们会在旧版本房间中发布新房间的链接 - 房间成员必须点击此链接才能加入新房间。", "room_version": "房间版本:", "room_version_section": "房间版本", - "space_predecessor": "查看%(spaceName)s的旧版本。", + "space_predecessor": "在 %(spaceName)s 中查看更旧的版本。", "space_upgrade_button": "将此空间升级到推荐的房间版本", - "unfederated": "此房间无法被远程 Matrix 服务器访问", - "upgrade_button": "升级此房间至版本 %(version)s", - "upgrade_dialog_description": "升级此房间需要关闭此房间的当前实例并创建一个新的房间代替它。为了给房间成员最好的体验,我们会:", - "upgrade_dialog_description_1": "创建一个拥有相同的名称、描述与头像的新房间", - "upgrade_dialog_description_2": "更新所有本地房间别名以使其指向新房间", - "upgrade_dialog_description_3": "阻止用户在旧房间中发言,并发送消息建议用户迁移至新房间", - "upgrade_dialog_description_4": "在新房间的开始处发送一条指回旧房间的链接,这样用户可以查看旧消息", - "upgrade_dialog_title": "更新房间版本", - "upgrade_dwarning_ialog_title_public": "更新公共房间", - "upgrade_warning_dialog_description": "更新房间是高级操作,通常建议在房间由于错误、缺失功能或安全漏洞而不稳定时使用。", - "upgrade_warning_dialog_explainer": "请注意升级将使这个房间有一个新版本。所有当前的消息都将保留在此存档房间中。", - "upgrade_warning_dialog_footer": "你将把此房间从 升级至 。", - "upgrade_warning_dialog_invite_label": "自动邀请该房间的成员加入新房间", - "upgrade_warning_dialog_report_bug_prompt": "这通常仅影响服务器如何处理房间。如果你的 %(brand)s 遇到问题,请回报错误。", - "upgrade_warning_dialog_report_bug_prompt_link": "通常这只影响房间在服务器上的处理方式。如果你对你的 %(brand)s 有问题,请报告一个错误。", - "upgrade_warning_dialog_title_private": "更新私人房间" + "unfederated": "远程 Matrix 服务器无法访问此房间", + "upgrade_button": "升级此房间到版本 %(version)s", + "upgrade_dialog_description": "升级此房间需要关闭当前的房间实例并创建一个新的房间来代替它。为了给房间成员提供最佳体验,我们将:", + "upgrade_dialog_description_1": "创建一个具有相同名称、描述与头像的新房间", + "upgrade_dialog_description_2": "更新任意本地房间别名以使其指向新房间", + "upgrade_dialog_description_3": "阻止用户在旧版本房间发言,并发送消息建议用户迁移到新房间。", + "upgrade_dialog_description_4": "在新房间的开头添加返回旧房间的链接,以便其他人可以查看旧消息。", + "upgrade_dialog_title": "升级房间版本", + "upgrade_dwarning_ialog_title_public": "升级公共房间", + "upgrade_warning_dialog_description": "升级房间是一项高级操作,通常建议在房间由于错误、功能缺失或安全漏洞而不稳定时执行此操作。", + "upgrade_warning_dialog_explainer": "请注意,升级将生成新版本的房间。所有当前消息都将保留在此已存档的房间中。", + "upgrade_warning_dialog_footer": "你将升级此房间的版本从 。", + "upgrade_warning_dialog_invite_label": "自动将此房间的成员邀请到新房间", + "upgrade_warning_dialog_report_bug_prompt": "这通常只会影响房间在服务器上的处理方式。如果你的 %(brand)s 出现问题,请报告 Bug。", + "upgrade_warning_dialog_report_bug_prompt_link": "这通常只会影响服务器上对房间的处理方式。如果你在使用 %(brand)s 时遇到问题,请报告 Bug。", + "upgrade_warning_dialog_title": "升级房间", + "upgrade_warning_dialog_title_private": "升级私有房间" }, "alias_not_specified": "未指定", "bridges": { - "description": "此房间正桥接消息到以下平台。了解更多。", - "empty": "这个房间不会将消息桥接到任何平台。了解更多", - "title": "桥接" + "description": "此房间正在将消息桥接到以下平台。了解详情", + "empty": "此房间不会将消息桥接到任何平台。了解更多", + "title": "桥接器" }, "delete_avatar_label": "删除头像", "general": { - "alias_field_has_domain_invalid": "缺少域分隔符,例子(:domain.org)", - "alias_field_has_localpart_invalid": "缺少房间名称或分隔符,例子(my-room:domain.org)", - "alias_field_matches_invalid": "此地址不指向此房间", - "alias_field_placeholder_default": "例如 my-room", + "alias_field_has_domain_invalid": "缺少域名分隔符。例如(:domain.org)", + "alias_field_has_localpart_invalid": "缺少房间名称或分隔符,例如(my-room:domain.org)", + "alias_field_matches_invalid": "此地址未指向此房间", + "alias_field_placeholder_default": "例如:my-room", "alias_field_required_invalid": "请提供地址", - "alias_field_safe_localpart_invalid": "不允许使用某些字符", - "alias_field_taken_invalid": "此地址的服务器无效或已被使用", + "alias_field_safe_localpart_invalid": "某些字符不被允许", + "alias_field_taken_invalid": "此地址指向的服务器无效或已被使用", "alias_field_taken_invalid_domain": "此地址已被使用", - "alias_field_taken_valid": "此地址可用", + "alias_field_taken_valid": "此地址可供使用", "alias_heading": "房间地址", - "aliases_items_label": "其他公布的地址:", - "aliases_no_items_label": "还没有其他公布的地址,在下方添加一个", + "aliases_items_label": "其它已发布地址:", + "aliases_no_items_label": "暂无其它已发布的地址,可以在下面添加。", "aliases_section": "房间地址", "avatar_field_label": "房间头像", "canonical_alias_field_label": "主要地址", - "description_space": "编辑关于你的空间的设置。", - "error_creating_alias_description": "创建地址时出现错误。可能是服务器不允许,也可能是出现了一个暂时的错误。", - "error_creating_alias_title": "创建地址时出现错误", - "error_deleting_alias_description": "删除那个地址时出现错误。可能它已不存在,也可能出现了一个暂时的错误。", - "error_deleting_alias_description_forbidden": "你没有权限删除此地址。", - "error_deleting_alias_title": "删除地址时出现错误", - "error_save_space_settings": "空间设置保存失败。", - "error_updating_alias_description": "更新此房间的备用地址时出现错误。可能是服务器不允许,也可能是出现了一个暂时的错误。", - "error_updating_canonical_alias_description": "更新房间的主要地址时发生错误。可能是此服务器不允许,也可能是出现了一个临时错误。", - "error_updating_canonical_alias_title": "更新主要地址时发生错误", + "description_space": "编辑空间相关设置。", + "error_creating_alias_description": "创建该地址时出错。服务器可能不允许该地址,或者发生了临时故障。", + "error_creating_alias_title": "创建地址时出错", + "error_deleting_alias_description": "移除此地址时出错。它可能不再存在或临时出错。", + "error_deleting_alias_description_forbidden": "你无权删除地址。", + "error_deleting_alias_title": "移除地址时出错", + "error_publishing": "无法发布房间", + "error_publishing_detail": "发布此房间时出错", + "error_save_space_settings": "保存空间设置时出错", + "error_updating_alias_description": "更新房间的备选地址时出错。服务器可能不允许此操作,或者发生了临时故障。", + "error_updating_canonical_alias_description": "更新房间主要地址时出错。服务器可能不允许这样做,或者发生了临时故障。", + "error_updating_canonical_alias_title": "更新主要地址时出错", "leave_space": "离开空间", "local_alias_field_label": "本地地址", - "local_aliases_explainer_room": "为此房间设置地址以便用户通过你的家服务器(%(localDomain)s)找到此房间", - "local_aliases_explainer_space": "设置此空间的地址,这样用户就能通过你的家服务器找到此空间(%(localDomain)s)", + "local_aliases_explainer_room": "为此房间设置地址,以便用户通过主服务器找到此房间。", + "local_aliases_explainer_space": "设置此空间的地址以便用户可以通过主服务器(%(localDomain)s)找到此空间", "local_aliases_section": "本地地址", "name_field_label": "房间名称", - "new_alias_placeholder": "新的公布的地址(例如 #alias:server)", + "new_alias_placeholder": "新的发布地址(例如:#alias:server)", "no_aliases_room": "此房间没有本地地址", "no_aliases_space": "此空间没有本地地址", - "other_section": "其他", - "publish_toggle": "是否将此房间发布至 %(domain)s 的房间目录中?", - "published_aliases_description": "要公布地址,首先需要将其设为本地地址。", - "published_aliases_explainer_room": "任何服务器上的人均可通过公布的地址加入你的房间。", - "published_aliases_explainer_space": "任何服务器上的人均可通过公布的地址加入你的空间。", - "published_aliases_section": "公布的地址", - "save": "保存修改", - "topic_field_label": "房间话题" + "other_section": "其它", + "publish_toggle": "发布此房间到 %(domain)s 的房间目录?", + "publish_warn_invite_only": "你无法发布被设为仅限邀请的房间。", + "publish_warn_no_canonical_permission": "你必须拥有设置主要地址的权限才能发布房间。", + "published_aliases_description": "要发布地址,首先需要将其设置为本地地址。", + "published_aliases_explainer_room": "任何服务器上的任何人都可以使用已发布的地址加入此房间。", + "published_aliases_explainer_space": "任何服务器上的任何人都可以使用已发布的地址加入此空间。", + "published_aliases_section": "发布的地址", + "save": "保存更改", + "topic_field_label": "房间主题" }, "notifications": { "browse_button": "浏览", "custom_sound_prompt": "设置新的自定义声音", "notification_sound": "通知声音", - "settings_link": "如设置中设定的那样获取通知", + "settings_link": "按设置中的配置获取通知", "sounds_section": "声音", + "upload_sound_label": "上传自定义声音", "uploaded_sound": "已上传的声音" }, + "people": { + "knock_empty": "暂无请求", + "knock_section": "申请加入", + "see_less": "查看更少", + "see_more": "查看更多" + }, "permissions": { - "add_privileged_user_description": "授权给该房间内的某人或某些人", - "add_privileged_user_filter_placeholder": "搜索该房间内的用户……", + "add_privileged_user_description": "授予此房间中一个或多个用户更多特权", + "add_privileged_user_filter_placeholder": "在此房间中搜索用户…", "add_privileged_user_heading": "添加特权用户", "ban": "封禁用户", "ban_reason": "理由", "banned_by": "被 %(displayName)s 封禁", - "banned_users_section": "被封禁的用户", - "error_changing_pl_description": "更改此用户的权力级别时出错。请确保你有足够权限后重试。", - "error_changing_pl_reqs_description": "更改此房间的权力级别需求时出错。请确保你有足够的权限后重试。", - "error_changing_pl_reqs_title": "更改权力级别需求时出错", - "error_changing_pl_title": "更改权力级别时出错", - "error_unbanning": "解除封禁失败", + "banned_users_section": "已被封禁的用户", + "error_changing_pl_description": "更改用户权力值时出错。请确保你拥有足够的权限并重试。", + "error_changing_pl_reqs_description": "更改房间权力值要求时出错。请确保你拥有足够的权限并重试。", + "error_changing_pl_reqs_title": "更改权力值要求时出错", + "error_changing_pl_title": "更改权力值时出错", + "error_unbanning": "解封失败", "events_default": "发送消息", "invite": "邀请用户", "kick": "移除用户", - "m.call": "开始%(brand)s呼叫", - "m.call.member": "加入%(brand)s呼叫", + "m.call": "开始 %(brand)s 通话", + "m.call.member": "加入 %(brand)s 通话", "m.reaction": "发送反应", "m.room.avatar": "更改房间头像", "m.room.avatar_space": "更改空间头像", - "m.room.canonical_alias": "更改房间主要地址", - "m.room.canonical_alias_space": "更改空间主地址", + "m.room.canonical_alias": "更改房间的主要地址", + "m.room.canonical_alias_space": "更改空间的主要地址", "m.room.encryption": "启用房间加密", - "m.room.history_visibility": "更改历史记录可见性", + "m.room.history_visibility": "更改历史可见性", "m.room.name": "更改房间名称", "m.room.name_space": "更改空间名称", - "m.room.pinned_events": "管理置顶事件", + "m.room.pinned_events": "管理已置顶事件", "m.room.power_levels": "更改权限", "m.room.redaction": "移除我发送的消息", - "m.room.server_acl": "更改服务器访问控制列表", - "m.room.tombstone": "更新房间", - "m.room.topic": "更改话题", + "m.room.server_acl": "更改服务器 ACL", + "m.room.tombstone": "升级房间", + "m.room.topic": "更改主题", "m.room.topic_space": "更改描述", - "m.space.child": "管理此空间中的房间", - "m.widget": "修改挂件", - "muted_users_section": "被禁言的用户", - "no_privileged_users": "此房间中没有用户有特殊权限", - "notifications.room": "通知每个人", + "m.space.child": "在此空间中管理房间", + "m.widget": "修改小部件", + "muted_users_section": "已被静默的用户", + "no_privileged_users": "此房间中的用户均无特定权限", + "notifications.room": "通知所有人", "permissions_section": "权限", - "permissions_section_description_room": "选择更改房间各个部分所需的角色", - "permissions_section_description_space": "选择改变空间各个部分所需的角色", + "permissions_section_description_room": "选择更改房间各部分所需的角色", + "permissions_section_description_space": "选择更改空间各部分所需的角色", "privileged_users_section": "特权用户", - "redact": "移除其他人的消息", + "redact": "移除其他人发送的消息", "send_event_type": "发送 %(eventType)s 事件", "state_default": "更改设置", "title": "角色与权限", "users_default": "默认角色" }, "security": { - "enable_encryption_confirm_description": "房间加密一经启用,便无法禁用。在加密房间中,发送的消息无法被服务器看到,只能被房间的参与者看到。启用加密可能会使许多机器人和桥接无法正常运作。 详细了解加密。", + "cannot_change_to_private_due_to_missing_history_visiblity_permissions": { + "description": "你无权更改该频道的历史可见性。此操作存在风险,可能导致未加入的用户读取消息。", + "title": "无法将房间设为私有房间" + }, + "enable_encryption_confirm_description": "房间的加密一旦启用就无法禁用。服务器无法看到在加密房间中发送的消息,只有房间内的参与者才能看到。启用加密可能会阻止大多数机器人与桥接器正常工作。了解更多加密相关", "enable_encryption_confirm_title": "启用加密?", - "enable_encryption_public_room_confirm_description_1": "不建议为公共房间添加加密。任何人都能找到并加入公共房间,所以任何人都能阅读其中的消息。你不会获得加密的任何好处,并且之后你无法将其关闭。在公共房间中加密消息会使接收和发送消息变慢。", - "enable_encryption_public_room_confirm_description_2": "为避免这些问题,请为计划中的对话创建一个新的加密房间。", - "enable_encryption_public_room_confirm_title": "你确定要为此公开房间开启加密吗?", - "encrypted_room_public_confirm_description_1": "不建议公开加密房间。这意味着任何人都可以找到并加入房间,因此任何人都可以阅读消息。你将不会得到任何加密带来的好处。在公共房间加密消息还会拖慢收发消息的速度。", - "encrypted_room_public_confirm_description_2": "为避免这些问题,请为计划中的对话创建一个新的加密房间。", - "encrypted_room_public_confirm_title": "你确定要公开此加密房间吗?", - "encryption_permanent": "加密一经启用,便无法禁用。", - "error_join_rule_change_title": "未能更新加入列表", - "error_join_rule_change_unknown": "未知失败", - "guest_access_warning": "拥有受支持客户端的人无需注册账户即可加入房间。", - "history_visibility_invited": "只有成员(从他们被邀请开始)", - "history_visibility_legend": "谁可以阅读历史消息?", - "history_visibility_shared": "仅成员(从选中此选项时开始)", - "history_visibility_warning": "历史记录阅读权限的更改只会应用到此房间中将来的消息。既有历史记录的可见性将不会更改。", - "history_visibility_world_readable": "任何人", + "enable_encryption_public_room_confirm_description_1": "不建议为公共房间启用加密。任何人都可以找到并加入公共房间,因此任何人都可以阅读其中的消息。这种情况下你将无法受益于加密,并且以后也无法关闭加密功能。在公共房间中,加密消息会降低消息的收发效率。", + "enable_encryption_public_room_confirm_description_2": "为避免这些问题,请为你计划进行的对话创建一个新的加密房间。", + "enable_encryption_public_room_confirm_title": "你确定要为此公共房间添加加密功能?", + "encrypted_room_public_confirm_description_1": "不建将加密房间设为公开。这意味着任何人都可以找到并加入公共房间,因此任何人都可以阅读其中的消息。这种情况下你将无法受益于加密。在公共房间中,加密消息会降低消息的收发效率。", + "encrypted_room_public_confirm_description_2": "为避免这些问题,请为你计划进行的对话创建一个新的公共房间。", + "encrypted_room_public_confirm_title": "你确定要公开此加密房间?", + "encryption_forced": "服务器要求禁用加密。", + "encryption_permanent": "加密一旦启用就无法禁用。", + "error_join_rule_change_title": "连接规则更新失败", + "error_join_rule_change_unknown": "未知故障", + "guest_access_warning": "使用受支持的客户端的人员无需注册账户即可加入房间。", + "history_visibility_invited": "自成员被邀请时起", + "history_visibility_legend": "谁可以查看历史?", + "history_visibility_shared": "成员(完整历史)", + "history_visibility_warning": "此更改不会影响过去的消息,而只会影响新消息。了解更多", + "history_visibility_world_readable": "任何人(公开历史)", "join_rule_description": "决定谁可以加入 %(roomName)s。", - "join_rule_invite": "私有(仅邀请)", - "join_rule_invite_description": "只有受邀的人才能加入。", + "join_rule_invite": "仅限邀请", + "join_rule_invite_description": "仅限被邀请的人员加入", "join_rule_knock": "申请加入", - "join_rule_public_description": "任何人都可以找到并加入。", + "join_rule_knock_description": "除非被授予权限, 否则人员无法加入。", + "join_rule_public": "任何人", + "join_rule_public_description": "任何人都可以加入。", "join_rule_restricted": "空间成员", - "join_rule_restricted_description": "空间中的任何人都可以找到并加入。在此处编辑哪些空间可以访问。", - "join_rule_restricted_description_active_space": " 中的任何人都可以寻找和加入。你也可以选择其他空间。", - "join_rule_restricted_description_prompt": "空间中的任何人都可以找到并加入。你可以选择多个空间。", - "join_rule_restricted_description_spaces": "可访问的空间", - "join_rule_restricted_dialog_description": "决定哪些空间可以访问这个房间。如果一个空间被选中,它的成员可以找到并加入。", - "join_rule_restricted_dialog_empty_warning": "你正在移除所有空间。访问权限将预设为仅邀请", + "join_rule_restricted_description": "位于被授权的空间的任何人无需邀请即可加入。管理空间", + "join_rule_restricted_description_active_space": "任何在 中的成员都可以加入。", + "join_rule_restricted_description_prompt": "在空间中的任何人都可以加入。", + "join_rule_restricted_description_spaces": "被授权的空间", + "join_rule_restricted_dialog_description": "无需邀请即可加入 。", + "join_rule_restricted_dialog_empty_warning": "你将移除所有已授权的空间。访问权将默认变更为“仅邀请”。", "join_rule_restricted_dialog_filter_placeholder": "搜索空间", - "join_rule_restricted_dialog_heading_other": "你可能不知道的其他空间或房间", - "join_rule_restricted_dialog_heading_room": "你知道的包含此房间的空间", - "join_rule_restricted_dialog_heading_space": "你知道的包含这个空间的空间", - "join_rule_restricted_dialog_heading_unknown": "这些可能是其他房间管理员的一部分。", - "join_rule_restricted_dialog_title": "选择空间", + "join_rule_restricted_dialog_heading_known": "此房间未被包含在你的空间", + "join_rule_restricted_dialog_heading_other": "其它你并非其成员的空间", + "join_rule_restricted_dialog_heading_room": "包含此房间的空间", + "join_rule_restricted_dialog_heading_space": "你已知包含到此空间的子空间", + "join_rule_restricted_dialog_heading_unknown": "这些可能是其他房间管理员参与的房间。", + "join_rule_restricted_dialog_title": "管理空间", "join_rule_restricted_n_more": { - "other": "以及另 %(count)s", - "one": "& 另外 %(count)s" + "one": "与更多 %(count)s 个", + "other": "与更多 %(count)s 个" }, "join_rule_restricted_summary": { - "other": "目前,%(count)s 个空间可以访问", - "one": "目前,一个空间有访问权限" + "one": "当前已授权 1 个空间", + "other": "当前已授权 %(count)s 个空间" }, - "join_rule_restricted_upgrade_description": "此升级将允许选定的空间成员无需邀请即可访问此房间。", - "join_rule_restricted_upgrade_warning": "这个房间位于你不是管理员的某些空间中。 在这些空间中,旧房间仍将显示,但系统会提示人们加入新房间。", - "join_rule_upgrade_awaiting_room": "正在加载新房间", + "join_rule_restricted_upgrade_description": "此升级将允许选定空间的成员无需邀请即可访问此房间。", + "join_rule_restricted_upgrade_warning": "此房间位于你并非管理员的某些空间中。在这些空间中,旧房间仍会显示,但系统会提示用户加入新房间。", + "join_rule_upgrade_awaiting_room": "正在载入新房间", "join_rule_upgrade_required": "需要升级", "join_rule_upgrade_sending_invites": { "one": "正在发送邀请…", - "other": "正在发送邀请… (%(count)s 中的 %(progress)s)" + "other": "正在发送邀请…(%(count)s 个中的第 %(progress)s 个)" }, "join_rule_upgrade_updating_spaces": { - "other": "正在更新房间… (%(count)s 中的 %(progress)s)", - "one": "正在更新空间…" + "one": "正在升级空间…", + "other": "正在更新空间…(%(count)s 个中的第 %(progress)s 个)" }, "join_rule_upgrade_upgrading_room": "正在升级房间", - "public_without_alias_warning": "要链接至此房间,请添加一个地址。", - "publish_room": "使此房间在公共房间目录中可见。", - "publish_space": "使此空间在公共房间目录中可见。", - "strict_encryption": "永不从此会话向此房间中未验证的会话发送加密消息", - "title": "隐私安全" + "join_rule_world_readable_description": "更改谁可以加入房间也会更改未来消息的可见性。", + "public_without_alias_warning": "要关联到此房间,请添加地址。", + "publish_room": "使此房间在公共房间目录可见。", + "publish_space": "使此空间在公共房间目录可见。", + "strict_encryption": "仅发送消息到已验证的用户。", + "title": "安全与隐私" }, - "title": "房间设置 - %(roomName)s", + "title": "房间设置:%(roomName)s", "upload_avatar_label": "上传头像", "visibility": { "alias_section": "地址", - "error_failed_save": "更新此空间的可见性失败", - "error_update_guest_access": "更新此空间的游客访问权限失败", - "error_update_history_visibility": "更新此空间的历史记录可见性失败", - "guest_access_explainer": "游客无需账户即可加入空间。", - "guest_access_label": "启用游客访问权限", + "error_failed_save": "此空间的设置更新失败", + "error_update_guest_access": "此空间的访客访问更新失败", + "error_update_history_visibility": "此空间的历史可见性更新失败", + "guest_access_disabled": "你无权更改访客访问。", + "guest_access_explainer": "访客可以在没有账户的情况下加入。这将对公共空间有用。", + "guest_access_label": "启用访客访问", "history_visibility_anyone_space": "预览空间", - "history_visibility_anyone_space_description": "允许人们在加入前预览你的空间。", - "history_visibility_anyone_space_recommendation": "建议用于公开空间。", - "title": "可见性" + "history_visibility_anyone_space_description": "允许人员在加入前预览空间。", + "history_visibility_anyone_space_disabled": "你无权更改历史可见性。", + "history_visibility_anyone_space_recommendation": "推荐用于公共空间。", + "title": "安全与隐私" }, "voip": { "call_type_section": "通话类型", - "enable_element_call_caption": "%(brand)s是端到端加密的,但是目前仅限于少数用户。", - "enable_element_call_label": "启用%(brand)s作为此房间的额外通话选项", - "enable_element_call_no_permissions_tooltip": "你没有足够的权限更改这个。" + "enable_element_call_caption": "%(brand)s 为端到端加密,但当前仅限少数用户。", + "enable_element_call_label": "在此房间启用 %(brand)s 作为额外通话选项", + "enable_element_call_no_permissions_tooltip": "你无权更改此设置。" } }, "room_summary_card_back_action_label": "房间信息", "scalar": { - "error_create": "无法创建挂件。", - "error_membership": "你不在此房间中。", - "error_missing_room_id": "缺少roomId。", - "error_missing_room_id_request": "请求中缺少room_id", - "error_missing_user_id_request": "请求中缺少user_id", - "error_permission": "你没有权限在此房间进行那个操作。", - "error_power_level_invalid": "权力级别必须是正整数。", - "error_room_not_visible": "房间%(roomId)s不可见", + "error_create": "无法创建小部件。", + "error_membership": "你不在此房间。", + "error_missing_room_id": "缺少房间 ID.", + "error_missing_room_id_request": "请求中缺少“room_id”", + "error_missing_user_id_request": "请求中缺少“user_id”", + "error_permission": "你无权在此房间内执行此操作。", + "error_power_level_invalid": "权力值必须为正整数。", + "error_room_not_visible": "房间 %(roomId)s 不可见", "error_room_unknown": "无法识别此房间。", "error_send_request": "请求发送失败。", - "failed_read_event": "读取时间失败", - "failed_send_event": "发送事件失败" + "failed_read_event": "事件读取失败", + "failed_send_event": "事件发送失败" }, "server_offline": { - "description": "你的服务器未响应你的一些请求。下方是一些最可能的原因。", - "description_1": "服务器(%(serverName)s)花了太长时间响应。", - "description_2": "你的防火墙或防病毒软件阻止了此请求。", - "description_3": "一个浏览器扩展阻止了此请求。", - "description_4": "此服务器为离线状态。", - "description_5": "此服务器拒绝了你的请求。", - "description_6": "你的区域难以连接上互联网。", - "description_7": "尝试联系服务器时出现连接错误。", - "description_8": "服务器没有配置为提示错误是什么(CORS)。", - "empty_timeline": "全数阅毕。", - "recent_changes_heading": "尚未被接受的最近更改", + "description": "服务器未响应你的某些请求。以下是一些最可能的原因。", + "description_1": "服务器(%(serverName)s)响应时间过长。", + "description_2": "你的防火墙或杀毒软件阻止了请求。", + "description_3": "浏览器扩展阻止了该请求。", + "description_4": "此服务器已离线。", + "description_5": "服务器已拒绝你的请求。", + "description_6": "你所在的区域在连接 Internet 时遇到困难。", + "description_7": "尝试联系服务器时发生连接错误。", + "description_8": "服务器的配置未能说明问题原因(CORS)。", + "empty_timeline": "你已阅读所有消息", + "recent_changes_heading": "尚未收到最近的更改", "title": "服务器未响应" }, + "service_worker_error": { + "description": "%(brand)s 需要一个 Service Worker 来从 Matrix 内容存储库加载经过身份验证的媒体。当前浏览器不支持此功能,因此你可能会遇到媒体加载失败的情况。", + "title": "Services Worker 载入失败" + }, "seshat": { - "error_initialising": "消息搜索初始化失败,请检查你的设置以获取更多信息", - "reset_button": "重置活动存储", - "reset_description": "你大概率不想重置你的活动缩影存储", - "reset_explainer": "如果这样做,请注意你的消息并不会被删除,但在重新建立索引时,搜索体验可能会降低片刻", - "reset_title": "重置活动存储?", - "warning_kind_files": "当前版本的 %(brand)s 不支持查看某些加密文件", - "warning_kind_files_app": "使用桌面端应用来查看所有加密文件", - "warning_kind_search": "当前版本的 %(brand)s 不支持搜索加密消息", - "warning_kind_search_app": "使用桌面端英语来搜索加密消息" + "error_initialising": "消息搜索初始化失败,请检查设置了解更多信息。", + "reset_button": "重置事件存储", + "reset_description": "你很可能不想重置事件索引存储", + "reset_explainer": "如果你这样做,请注意,你的任何消息都不会被删除,但在重新创建索引期间,搜索体验可能会暂时下降。", + "reset_title": "重置事件存储?", + "warning_kind_files": "此版本的 %(brand)s 不支持查看某些加密文件", + "warning_kind_files_app": "使用桌面 App 查看所有加密文件", + "warning_kind_search": "此版本的 %(brand)s 不支持搜索加密消息", + "warning_kind_search_app": "使用桌面 App 搜索加密消息" }, "setting": { "help_about": { - "access_token_detail": "你的访问令牌可以完全访问你的账户。不要将其与任何人分享。", + "access_token_detail": "访问 Token 可以完全控制账户,请勿分享给任何人。", "brand_version": "%(brand)s 版本:", - "clear_cache_reload": "清理缓存并重载", - "help_link": "关于 %(brand)s 的使用说明。", - "homeserver": "服务器介绍:%(homeserverUrl)s", - "title": "帮助及关于", + "clear_cache_reload": "清除缓存并重载", + "crypto_version": "加密组件版本:", + "dialog_title": "设置:帮助与关于", + "help_link": "点击此处获取有关使用 %(brand)s 的帮助。", + "homeserver": "主服务器 URL:%(homeserverUrl)s", + "identity_server": "身份服务器为 %(identityServerUrl)s", + "title": "帮助与关于", "versions": "版本" } }, "settings": { + "account": { + "dialog_title": "设置:账户", + "title": "账户" + }, "all_rooms_home": "在主页显示所有房间", - "all_rooms_home_description": "你加入的所有房间都会显示在主页。", - "always_show_message_timestamps": "总是显示消息时间戳", + "all_rooms_home_description": "你加入的所有房间都将显示在主页。", + "always_show_message_timestamps": "始终显示消息时间戳", "appearance": { + "bundled_emoji_font": "使用内置的 Emoji 样式", + "compact_layout": "显示紧凑文字及消息", + "compact_layout_description": "必须选择“现代布局”以启用此功能。", "custom_font": "使用系统字体", - "custom_font_description": "设置一个安装在你的系统上的字体名称,%(brand)s 会尝试使用它。", + "custom_font_description": "设置当前系统已安装的字体名称,%(brand)s 将尝试使用该字体。", "custom_font_name": "系统字体名称", - "custom_font_size": "使用自定义大小", - "custom_theme_error_downloading": "下载主题信息时发生错误。", - "custom_theme_invalid": "主题方案无效。", - "font_size": "字体大小", + "custom_font_size": "使用自定义字号", + "custom_theme_add": "添加自定义主题", + "custom_theme_downloading": "正在下载自定义主题…", + "custom_theme_error_downloading": "下载主题时出错", + "custom_theme_help": "输入要应用的自定义主题的 URL。", + "custom_theme_invalid": "主题协议无效。", + "dialog_title": "设置:外观", + "font_size": "文字大小", + "font_size_default": "%(fontSize)s(默认)", + "high_contrast": "高对比度", "image_size_default": "默认", - "image_size_large": "大", + "image_size_large": "大型", "layout_bubbles": "消息气泡", "layout_irc": "IRC(实验性)", - "match_system_theme": "匹配系统主题", - "timeline_image_size": "时间线中的图像大小" + "match_system_theme": "跟随系统主题", + "timeline_image_size": "时间线中的图像尺寸" }, - "automatic_language_detection_syntax_highlight": "启用语法高亮的自动语言检测", + "automatic_language_detection_syntax_highlight": "启用“自动检测语法高亮中的编程语言”", "autoplay_gifs": "自动播放 GIF", "autoplay_videos": "自动播放视频", - "big_emoji": "在聊天中启用大型表情符号", + "big_emoji": "在聊天中启用加大的 Emoji", "code_block_expand_default": "默认展开代码块", - "code_block_line_numbers": "在代码块中显示行号", - "disable_historical_profile": "在消息历史记录中显示用户当前的头像和姓名", - "emoji_autocomplete": "启用实时表情符号建议", - "enable_markdown": "启用Markdown", - "enable_markdown_description": "以/plain 开始发送 Markdown 格式的信息。", + "code_block_line_numbers": "显示代码块中的行号", + "disable_historical_profile": "在消息历史中显示用户当前名称与个人资料图像", + "discovery": { + "title": "如何找到你" + }, + "emoji_autocomplete": "在键入期间启用 Emoji 建议", + "enable_markdown": "启用 Markdown", + "enable_markdown_description": "在消息开头输入 /plain 以临时禁用 Markdown。", + "encryption": { + "advanced": { + "breadcrumb_first_description": "你的账户的详细信息、联系人、偏好设置与聊天列表将被保留", + "breadcrumb_page": "重置加密", + "breadcrumb_second_description": "你将丢失所有仅存储在服务器上的消息历史", + "breadcrumb_third_description": "你将需要再次验证所有现有设备与联系人", + "breadcrumb_title": "你确定要重置数字身份?", + "breadcrumb_title_cant_confirm": "你需要重置数字身份", + "breadcrumb_title_forgot": "忘记恢复密钥?你需要重置数字身份。", + "breadcrumb_title_sync_failed": "同步密钥存储失败。你需要重置数字身份。", + "breadcrumb_warning": "仅当你认为账户被盗时才这么做。", + "details_title": "加密详细信息", + "do_not_close_warning": "在重置完成之前请勿关闭此窗口", + "export_keys": "导出密钥", + "import_keys": "导入密钥", + "other_people_device_description": "警告:未向你明确验证(例如使用 Emoji)的用户、已验证用户的未验证设备都将不会收到你的加密消息。要使此更改生效需要重新启动应用程序。", + "other_people_device_label": "在加密房间中仅发送消息到已验证的用户", + "other_people_device_title": "其他人的设备", + "reset_identity": "重置密码学身份", + "reset_in_progress": "正在重置…", + "session_id": "会话 ID:", + "session_key": "会话密钥:", + "title": "高级" + }, + "confirm_key_storage_off": "你确定要持续关闭密钥存储?", + "confirm_key_storage_off_description": "如果你移除所有设备将丢失消息历史,并需要重新验证所有现有联系人。了解更多", + "delete_key_storage": { + "breadcrumb_page": "删除密钥存储", + "confirm": "删除密钥存储", + "description": "删除密钥存储将从服务器中移除密码学身份与消息密钥,并关闭以下安全功能:", + "list_first": "你将不会在新设备上拥有加密消息历史", + "list_second": "如果你不再登录任何设备,你将丢失对加密消息的访问权。", + "title": "你确定要关闭密钥存储并将其删除?" + }, + "device_not_verified_button": "验证此设备", + "device_not_verified_description": "你需要验证此设备才能查看加密设置。", + "device_not_verified_title": "设备未验证", + "dialog_title": "设置:加密", + "key_storage": { + "allow_key_storage": "允许密钥存储", + "description": "这将允许你在任意新设备上查看聊天历史,这对备份聊天和数字身份是必需的。", + "title": "密钥存储" + }, + "recovery": { + "change_recovery_confirm_button": "确认新的恢复密钥", + "change_recovery_confirm_description": "请在下面输入新的恢复密钥以完成验证。你的旧密钥将不再有效。", + "change_recovery_confirm_title": "输入新的恢复密钥", + "change_recovery_key": "更改恢复密钥", + "change_recovery_key_description": "在安全的地方记下新的恢复密钥。点击“继续”按钮以确认更改。", + "change_recovery_key_title": "更改恢复密钥?", + "description": "你的聊天已被端到端加密自动备份。如果你无法访问所有设备,则需要使用恢复密钥恢复备份并保留数字身份。", + "enter_key_error": "你输入的恢复密钥不正确。", + "enter_recovery_key": "输入恢复密钥", + "forgot_recovery_key": "忘记恢复密钥?", + "key_storage_warning": "你的密钥存储不同步。点击以下按钮之一即可修复此问题。", + "save_key_description": "不要与任何人分享!", + "save_key_title": "恢复密钥", + "set_up_recovery": "获取恢复密钥", + "set_up_recovery_confirm_button": "完成设置", + "set_up_recovery_confirm_description": "请输入上一屏幕显示的恢复密钥以完成恢复设置。", + "set_up_recovery_confirm_title": "输入恢复密钥以确认", + "set_up_recovery_description": "你的密钥存储受恢复密钥保护。如果设置后需要新的恢复密钥,可以选择“%(changeRecoveryKeyButton)s”重新创建。", + "set_up_recovery_save_key_description": "请将此恢复密钥保存到安全的地方,例如密码管理器、加密笔记或物理保险箱。", + "set_up_recovery_save_key_title": "保存恢复密钥到安全的地方", + "set_up_recovery_secondary_description": "点击“继续”后,我们将生成恢复密钥。", + "title": "备份" + }, + "title": "加密" + }, "general": { "account_management_section": "账户管理", "account_section": "账户", - "add_email_dialog_title": "添加邮箱", - "add_email_failed_verification": "邮箱验证失败:请确保你已点击邮件中的链接", - "add_email_instructions": "我们已向你发送了一封电子邮件,以验证你的地址。 请按照里面的说明操作,然后单击下面的按钮。", - "add_msisdn_confirm_body": "点击下面的按钮,以确认添加此电话号码。", + "add_email_dialog_title": "添加邮件地址", + "add_email_failed_verification": "邮件地址验证失败:请确保点击了邮件中的链接。", + "add_email_instructions": "我们已向你发送了一封邮件,用于验证你的地址。请按照邮件中的说明操作,然后点击以下按钮。", + "add_msisdn_confirm_body": "点击以下按钮确认添加此电话号码。", "add_msisdn_confirm_button": "确认添加电话号码", - "add_msisdn_confirm_sso_button": "通过单点登录以证明你的身份,并确认添加此电话号码。", + "add_msisdn_confirm_sso_button": "请确认使用单点登录添加此电话号码以证明身份。", "add_msisdn_dialog_title": "添加电话号码", - "add_msisdn_instructions": "一封短信已发送至 +%(msisdn)s。请输入其中包含的验证码。", - "add_msisdn_misconfigured": "MSISDN的新增/绑定流程配置错误", - "confirm_adding_email_body": "点击下面的按钮,以确认添加此邮箱地址。", - "confirm_adding_email_title": "确认添加邮箱", - "deactivate_confirm_body": "你确定要停用你的账户吗?此操作不可逆。", - "deactivate_confirm_body_sso": "通过单点登录证明你的身份并确认停用你的账户。", - "deactivate_confirm_content_1": "你将无法重新激活你的账户", - "deactivate_confirm_continue": "确认账户停用", + "add_msisdn_instructions": "一条短信已发送到 +%(msisdn)s。请输入短信中包含的验证码。", + "add_msisdn_misconfigured": "添加或绑定 MSISDN 流程配置错误", + "allow_spellcheck": "允许拼写检查", + "application_language": "应用程序语言", + "application_language_reload_hint": "选择另一种语言将重载 App", + "avatar_open_menu": "打开头像菜单", + "avatar_remove_progress": "正在移除图像…", + "avatar_save_progress": "正在上传图像…", + "avatar_upload_error_text": "不支持的文件格式或图像大于 %(size)s。", + "avatar_upload_error_text_generic": "文件格式可能不受支持。", + "avatar_upload_error_title": "无法上传头像", + "confirm_adding_email_body": "点击以下按钮确认添加此邮件地址。", + "confirm_adding_email_title": "确认添加邮件", + "deactivate_confirm_body": "你确定要停用账户?这将不可逆转。", + "deactivate_confirm_body_sso": "要确认停用你的账户,请使用单点登录以证明身份。", + "deactivate_confirm_content": "确认要停用你的账户。如果你继续:", + "deactivate_confirm_content_1": "你将无法重新激活账户", + "deactivate_confirm_content_2": "你将无法再登录", + "deactivate_confirm_content_3": "任何人都无法重复使用你的用户名 (MXID),包括你自己:此用户名将保持不可用状态。", + "deactivate_confirm_content_4": "你将离开你所在的所有房间与私聊", + "deactivate_confirm_content_5": "你将被从身份服务器中移除:你的好友将无法再通过你的邮件地址或电话号码找到你。", + "deactivate_confirm_content_6": "你的旧消息仍然会被接收者看到,就像你过去发送的邮件一样。是否对以后加入房间的人员隐藏你发送的消息?", + "deactivate_confirm_continue": "确认停用账户", + "deactivate_confirm_erase_label": "隐藏我的信息不被新加入者看到", "deactivate_section": "停用账户", - "deactivate_warning": "停用你的账户是永久性动作——小心!", - "discovery_email_empty": "你在上方添加邮箱后发现选项将会出现。", - "discovery_email_verification_instructions": "验证你的收件箱中的链接", - "discovery_msisdn_empty": "你添加电话号码后发现选项将会出现。", - "discovery_needs_terms": "同意身份服务器(%(serverName)s)的服务协议以允许自己被通过邮件地址或电话号码发现。", - "email_address_in_use": "此邮箱地址已被使用", - "email_address_label": "电子邮箱地址", - "email_not_verified": "你的邮件地址尚未被验证", - "email_verification_instructions": "点击你所收到的电子邮件中的链接进行验证,然后再次点击继续。", - "emails_heading": "电子邮箱地址", - "error_add_email": "无法添加邮箱地址", - "error_deactivate_communication": "联系服务器时出现问题。请重试。", - "error_deactivate_invalid_auth": "服务器未返回有效认证信息。", - "error_deactivate_no_auth": "服务器不要求任何认证", - "error_email_verification": "无法验证邮箱地址。", - "error_invalid_email": "邮箱地址格式错误", - "error_invalid_email_detail": "这似乎不是有效的邮箱地址", + "deactivate_warning": "停用账户是一项永久性操作,请务必小心!", + "discovery_email_empty": "添加邮件后,将出现发现选项。", + "discovery_email_verification_instructions": "验证收件箱中的链接", + "discovery_msisdn_empty": "添加电话号码后将出现发现选项。", + "discovery_needs_terms": "同意身份服务器 (%(serverName)s) 的服务条款,以便允许他人通过邮件地址或电话号码找到你。", + "discovery_needs_terms_title": "让人们找到你", + "display_name": "显示名称", + "display_name_error": "无法设置显示名称", + "email_adding_unsupported_by_hs": "此主服务器不支持向账户添加邮件地址。", + "email_address_in_use": "此邮件地址已被使用", + "email_address_label": "邮件地址", + "email_not_verified": "你的邮件地址尚未经过验证", + "email_verification_instructions": "点击你收到的邮件中的链接进行验证,然后再次点击“继续”。", + "emails_heading": "邮件地址", + "error_add_email": "无法添加邮件地址", + "error_deactivate_communication": "与服务器通信时出现问题。请重试。", + "error_deactivate_invalid_auth": "服务器未能返回有效的身份验证信息。", + "error_deactivate_no_auth": "服务器无需任何身份验证", + "error_email_verification": "无法验证邮件地址。", + "error_invalid_email": "无效邮件地址", + "error_invalid_email_detail": "这似乎不是有效的邮件地址", "error_msisdn_verification": "无法验证电话号码。", - "error_password_change_403": "修改密码失败。确认原密码输入正确吗?", + "error_password_change_403": "密码更改失败。密码是否正确?", + "error_password_change_http": "%(errorMessage)s(HTTP 状态码 %(httpStatus)s)", + "error_password_change_title": "更改密码时出错", + "error_password_change_unknown": "更改密码时出现未知错误 (%(stringifiedError)s)", "error_remove_3pid": "无法移除联系人信息", - "error_revoke_email_discovery": "无法撤消电子邮件地址共享", - "error_revoke_msisdn_discovery": "无法撤销电话号码共享", - "error_share_email_discovery": "无法共享邮件地址", - "error_share_msisdn_discovery": "无法共享电话号码", - "identity_server_no_token": "找不到身份访问令牌", - "identity_server_not_set": "身份服务器未设置", - "language_section": "语言与地区", + "error_revoke_email_discovery": "无法撤消分享的邮件地址", + "error_revoke_msisdn_discovery": "无法找到与此电话号码对应的分享", + "error_share_email_discovery": "无法分享邮件地址", + "error_share_msisdn_discovery": "无法分享电话号码", + "identity_server_no_token": "未找到身份访问 Token", + "identity_server_not_set": "未设置身份服务器", + "invalid_phone_number": "提供的电话号码似乎无效。", + "language_section": "语言", + "msisdn_adding_unsupported_by_hs": "此主服务器不支持向账户添加电话号码。", "msisdn_in_use": "此电话号码已被使用", "msisdn_label": "电话号码", "msisdn_verification_field_label": "验证码", - "msisdn_verification_instructions": "请输入短信中发送的验证码。", + "msisdn_verification_instructions": "请输入通过短信发送的验证码。", "msisdns_heading": "电话号码", - "password_change_section": "设置一个新密码", - "password_change_success": "你的密码已成功更改。", - "remove_email_prompt": "删除 %(email)s 吗?", - "remove_msisdn_prompt": "删除 %(phone)s 吗?", - "spell_check_locale_placeholder": "选择区域设置" + "oidc_manage_button": "管理账户", + "password_change_section": "设置新的账户密码…", + "password_change_success": "密码已成功更改。", + "personal_info": "个人信息", + "profile_subtitle": "这是你在 App 上向他人展示的形象。", + "profile_subtitle_oidc": "你的账户由身份提供者单独管理,因此某些个人信息无法在此处更改。", + "remove_email_prompt": "移除 %(email)s?", + "remove_msisdn_prompt": "移除 %(phone)s?", + "spell_check_locale_placeholder": "选择区域", + "unable_to_load_emails": "无法加载邮件地址", + "unable_to_load_msisdns": "无法载入电话号码", + "username": "用户名" }, - "inline_url_previews_default": "默认启用行内URL预览", - "insert_trailing_colon_mentions": "在消息开头的提及用户的地方后面插入尾随冒号", - "jump_to_bottom_on_send": "发送消息时跳转到时间线底部", + "inline_url_previews_default": "启用预览", + "inline_url_previews_encrypted": "在加密房间启用 URL 预览", + "insert_trailing_colon_mentions": "在位于消息开头的用户提及后插入一个冒号", + "invite_controls": { + "default_label": "允许用户邀请你到房间" + }, + "jump_to_bottom_on_send": "发送消息后跳转到时间线末尾", "key_backup": { "setup_secure_backup": { - "cancel_warning": "如果你现在取消,你可能会丢失加密的消息和数据,如果你丢失了登录信息的话。", - "confirm_security_phrase": "确认你的安全短语", - "description": "通过在你的服务器上备份加密密钥来防止丢失你对加密消息和数据的访问权。", - "download_or_copy": "%(downloadButton)s或%(copyButton)s", - "enter_phrase_title": "输入安全短语", - "enter_phrase_to_confirm": "再次输入你的安全短语进行确认。", - "generate_security_key_description": "我们将为您生成一个安全密钥,将其存储在安全的地方,例如密码管理器或保险箱。", - "generate_security_key_title": "生成一个安全密钥", - "pass_phrase_match_failed": "不匹配。", - "pass_phrase_match_success": "匹配成功!", - "phrase_strong_enough": "棒!这个安全短语看着够强。", + "backup_setup_success_description": "你的密钥正在从此设备备份。", + "backup_setup_success_title": "安全备份成功", + "cancel_warning": "如果你现在取消,在无法访问登录信息的情况下,可能会丢失加密消息与数据。", + "confirm_security_phrase": "确认安全口令", + "description": "通过在服务器上备份加密密钥,防止丢失对加密消息和数据的访问权。", + "download_or_copy": "%(downloadButton)s 或 %(copyButton)s", + "enter_phrase_description": "输入只有你知道的安全口令用于保护你的数据。为了安全起见,请勿重复使用账户密码。", + "enter_phrase_title": "输入安全口令", + "enter_phrase_to_confirm": "再次输入安全口令以确认。", + "generate_security_key_description": "我们将为你生成一个恢复密钥,以便你将其存储在安全的地方,例如密码管理器或保险箱。", + "generate_security_key_title": "生成恢复密钥", + "pass_phrase_match_failed": "不匹配", + "pass_phrase_match_success": "匹配!", + "phrase_strong_enough": "很好!此口令看起来足够强。", "secret_storage_query_failure": "无法查询秘密存储状态", - "security_key_safety_reminder": "将您的安全密钥存放在安全的地方,例如密码管理器或保险箱,因为它用于保护您的加密数据。", - "set_phrase_again": "返回重新设置。", - "settings_reminder": "你也可以在设置中设置安全备份并管理你的密钥。", - "title_confirm_phrase": "确认安全密码", - "title_save_key": "保存你的安全密钥", - "title_set_phrase": "设置一个安全密码", + "security_key_safety_reminder": "请将恢复密钥存储在安全的地方,例如密码管理器或保险箱,因为它用于保护你的加密数据。", + "set_phrase_again": "返回以再次设置。", + "settings_reminder": "你还可以在“设置”中设置安全备份与管理密钥。", + "title_confirm_phrase": "确认安全口令", + "title_save_key": "保存恢复密钥", + "title_set_phrase": "设置安全口令", "unable_to_setup": "无法设置秘密存储", - "use_different_passphrase": "使用不同的口令词组?", - "use_phrase_only_you_know": "使用一个只有你知道的密码,你也可以保存安全密钥以供备份使用。" + "use_different_passphrase": "使用不同的口令?", + "use_phrase_only_you_know": "使用只有你知道的密码,并可选择保存恢复密钥以用于备份。" } }, "key_export_import": { - "confirm_passphrase": "确认口令词组", - "enter_passphrase": "输入口令词组", - "export_description_1": "此操作允许你将加密房间中收到的消息的密钥导出为本地文件。你可以将文件导入其他 Matrix 客户端,以便让别的客户端在未收到密钥的情况下解密这些消息。", + "confirm_passphrase": "确认口令", + "enter_passphrase": "输入口令", + "export_description_1": "此过程允许你将加密房间中收到的消息的密钥导出到本地文件。你之后可以将该文件导入到另一个 Matrix 客户端以便该客户端也能解密这些消息。", + "export_description_2": "导出的文件将允许任何可以读取它的人解密你可以看到的任何加密消息,应小心确保其安全。为此你应该在下面输入一个唯一的口令,该口令仅用于加密导出的数据,并且只能使用相同的口令导入数据。", "export_title": "导出房间密钥", "file_to_import": "要导入的文件", - "import_description_1": "此操作允许你导入之前从另一个 Matrix 客户端中导出的加密密钥文件。导入完成后,你将能够解密那个客户端可以解密的加密消息。", - "import_description_2": "导出文件受口令词组保护。你应该在此输入口令词组以解密此文件。", + "import_description_1": "此过程允许你导入以前从其它 Matrix 客户端导出的加密密钥,然后你将能够解密其它客户端可以解密的任何消息。", + "import_description_2": "已导出的文件使用口令进行保护。你应该在此处输入口令来解密此文件。", "import_title": "导入房间密钥", - "phrase_cannot_be_empty": "口令词组不能为空", - "phrase_must_match": "口令词组必须匹配" + "phrase_cannot_be_empty": "口令不能为空", + "phrase_must_match": "口令必须匹配", + "phrase_strong_enough": "很好!此口令看起来足够强" }, "keyboard": { + "dialog_title": "设置:键盘", "title": "键盘" }, + "labs": { + "dialog_title": "设置:实验室" + }, + "labs_mjolnir": { + "dialog_title": "设置:已忽略的用户" + }, + "media_preview": { + "hide_avatars": "隐藏房间与邀请者的头像", + "hide_media": "始终隐藏", + "media_preview_description": "点击隐藏的媒体即可将其恢复显示", + "media_preview_label": "在时间线上显示媒体", + "show_in_private": "在私有房间", + "show_media": "始终显示" + }, + "not_supported": "服务器尚未实现此功能。", "notifications": { - "enable_audible_notifications_session": "为此会话启用声音通知", + "default_setting_description": "此设置将默认应用于你的所有房间。", + "default_setting_section": "要接收的通知类型(默认设置)", + "desktop_notification_message_preview": "在桌面通知中显示消息预览", + "dialog_title": "设置:通知", + "email_description": "接收错过的通知的邮件摘要", + "email_section": "邮件摘要", + "email_select": "选择要向其发送摘要的邮件地址。可以在中管理邮件地址。", + "enable_audible_notifications_session": "为此会话的通知启用声音", "enable_desktop_notifications_session": "为此会话启用桌面通知", - "enable_email_notifications": "为 %(email)s 启用电子邮件通知", + "enable_email_notifications": "为 %(email)s 启用邮件通知", "enable_notifications_account": "为此账户启用通知", - "enable_notifications_account_detail": "关闭以在你全部设备和会话上停用通知", - "enable_notifications_device": "为此设备启用通知", - "error_loading": "加载你的通知设置时出错。", - "error_permissions_denied": "%(brand)s 没有通知发送权限 - 请检查你的浏览器设置", - "error_permissions_missing": "%(brand)s 没有通知发送权限 - 请重试", - "error_saving": "保存通知偏好时出错", - "error_saving_detail": "保存你的通知偏好时出错。", + "enable_notifications_account_detail": "关闭此选项将禁用你所有设备及会话的通知", + "enable_notifications_device": "启用此设备的通知", + "error_loading": "加载通知设置时出错。", + "error_permissions_denied": "%(brand)s 无权向你发送通知,请检查浏览器设置。", + "error_permissions_missing": "%(brand)s 未被授予发送通知的权限,请重试。", + "error_saving": "保存通知设置时出错", + "error_saving_detail": "保存通知偏好时出错。", "error_title": "无法启用通知", - "messages_containing_keywords": "当消息包含关键词时", + "error_updating": "更新通知首选项时出错。请尝试再次切换选项。", + "invites": "邀请到房间", + "keywords": "在房间中使用特定关键词时显示 标记。", + "keywords_prompt": "在此处输入关键词、拼写变体或昵称", + "labs_notice_prompt": "更新:我们简化了通知设置使选项更易于查找。你曾经选择的某些自定义设置不会在此处显示,但它们仍然处于活动状态。如果继续,你的某些设置可能会更改。了解更多", + "mentions_keywords": "提及与关键词", + "mentions_keywords_only": "仅提及与关键词", + "messages_containing_keywords": "消息包含的关键词", "noisy": "响铃", + "notices": "由机器人发送的消息", + "notify_at_room": "当有人提及使用“@room”时通知", + "notify_keyword": "当有人使用关键字时通知", + "notify_mention": "当有人提及使用“@displayname”或“%(mxid)s”时通知", + "other_section": "你可能感兴趣的其它内容:", + "people_mentions_keywords": "人员、提及与关键词", + "play_sound_for_description": "此设置将默认应用于你所有设备中的所有房间。", + "play_sound_for_section": "播放声音", "push_targets": "通知目标", - "rule_call": "当受到通话邀请时", - "rule_contains_display_name": "当消息包含我的显示名称时", - "rule_contains_user_name": "当消息包含我的用户名时", + "quick_actions_mark_all_read": "所有消息设为已读", + "quick_actions_reset": "重置为默认设置", + "quick_actions_section": "快速设置", + "room_activity": "新产生的房间活动、升级与状态消息", + "rule_call": "通话邀请", + "rule_contains_display_name": "包含我的显示名称的消息", + "rule_contains_user_name": "包含我的用户名的消息", "rule_encrypted": "群聊中的加密消息", - "rule_encrypted_room_one_to_one": "私聊中的加密消息", - "rule_invite_for_me": "当我被邀请进入房间", + "rule_encrypted_room_one_to_one": "一对一聊天中的加密消息", + "rule_invite_for_me": "当我受邀到房间", "rule_message": "群聊中的消息", - "rule_room_one_to_one": "私聊中的消息", - "rule_roomnotif": "当消息包含 @room 时", - "rule_suppress_notices": "由机器人发出的消息", - "rule_tombstone": "当房间升级时", - "show_message_desktop_notification": "在桌面通知中显示消息" + "rule_room_one_to_one": "一对一聊天中的消息", + "rule_roomnotif": "包含 @room 的消息", + "rule_suppress_notices": "由机器人发送的消息", + "rule_tombstone": "当房间升级时。", + "show_message_desktop_notification": "在桌面通知中显示消息内容", + "voip": "音频与视频通话" }, "preferences": { - "always_show_menu_bar": "总是显示窗口菜单栏", - "autocomplete_delay": "自动完成延迟(毫秒)", + "Electron.enableContentProtection": "阻止窗口内容被其它应用程序捕获", + "Electron.enableHardwareAcceleration": "启用硬件加速(需要重新启动 %(appName)s 以生效)", + "always_show_menu_bar": "始终显示窗口菜单栏", + "autocomplete_delay": "自动补全延时(毫秒)", "code_blocks_heading": "代码块", - "compact_modern": "使用更紧凑的“现代”布局", + "compact_modern": "使用更简洁的“现代”布局", "composer_heading": "编辑器", + "default_timezone": "浏览器默认(%(timezone)s)", + "dialog_title": "设置:偏好", + "enable_content_protection": "启用内容保护", "enable_hardware_acceleration": "启用硬件加速", - "enable_tray_icon": "显示托盘图标并在关闭时最小化窗口至托盘", + "enable_tray_icon": "当窗口关闭时最小化其到托盘", "keyboard_heading": "键盘快捷键", - "keyboard_view_shortcuts_button": "要查看所有的键盘快捷键,点击此处。", - "media_heading": "图片、GIF 和视频", - "presence_description": "与别人分享你的活动和状态。", - "rm_lifetime": "已读标记生存期(毫秒)", - "rm_lifetime_offscreen": "已读标记屏幕外生存期(毫秒)", + "keyboard_view_shortcuts_button": "要查看所有键盘快捷键请点击此处。", + "link_previews_description": "在消息下方显示链接相关信息", + "link_previews_heading": "链接预览", + "media_heading": "图像、GIF 与视频", + "presence_description": "向其他人分享你的活跃情况。", + "publish_timezone": "在公开资料上公布时区", + "rm_lifetime": "已读标记生存时间(毫秒)", + "rm_lifetime_offscreen": "已读标记屏外生存时间(毫秒)", + "room_directory_heading": "房间目录", "room_list_heading": "房间列表", + "show_avatars_pills": "在用户、房间与事件提及中显示头像", "show_polls_button": "显示投票按钮", - "surround_text": "输入特殊字符时圈出选定的文本", - "time_heading": "显示的时间戳" + "startup_window_behaviour_label": "启动时的窗口行为", + "surround_text": "当键入特殊字符时圈选文字", + "time_heading": "显示时间", + "user_timezone": "设置时区" }, - "prompt_invite": "在发送邀请之前提示可能无效的 Matrix ID", - "replace_plain_emoji": "自动取代纯文本为表情符号", + "prompt_invite": "向可能无效的 Matrix ID 发送邀请前提示", + "replace_plain_emoji": "自动替换纯文本 Emoji", "security": { - "bulk_options_accept_all_invites": "接受所有 %(invitedRooms)s 邀请", - "bulk_options_reject_all_invites": "拒绝所有 %(invitedRooms)s 的邀请", + "analytics_description": "分享匿名数据已帮助我们识别问题。不涉及个人隐私及第三方。", + "bulk_options_accept_all_invites": "接受所有 %(invitedRooms)s 的邀请", + "bulk_options_reject_all_invites": "拒绝所有 %(invitedRooms)s 邀请", "bulk_options_section": "批量选择", - "e2ee_default_disabled_warning": "你的服务器管理员默认关闭了私人房间和私聊中的端到端加密。", - "enable_message_search": "在加密房间中启用消息搜索", + "dehydrated_device_description": "离线设备功能允许你即使未登录任何设备也能接收加密消息。", + "dehydrated_device_enabled": "离线设备已启用", + "dialog_title": "设置:安全与隐私", + "e2ee_default_disabled_warning": "私有房间与私聊默认的端到端加密已被服务器管理员禁用。", + "enable_message_search": "在加密房间启用消息搜索", "encryption_section": "加密", - "ignore_users_empty": "你没有设置忽略用户。", + "ignore_users_empty": "暂无已忽略的用户", "ignore_users_section": "已忽略的用户", "key_backup_algorithm": "算法:", - "message_search_disable_warning": "如果被禁用,加密房间内的消息不会显示在搜索结果中。", - "message_search_disabled": "在本地安全地缓存加密消息以使其出现在搜索结果中。", + "message_search_disable_warning": "如果停用此功能,来自加密房间的消息将不会显示在搜索结果中。", + "message_search_disabled": "在安全地在本地缓存加密消息以使其出现在搜索结果中。", "message_search_enabled": { - "one": "使用%(size)s存储%(rooms)s个房间的消息。在本地安全地缓存已加密的消息以使其出现在搜索结果中。", - "other": "使用%(size)s存储%(rooms)s个房间的消息。在本地安全地缓存已加密的消息以使其出现在搜索结果中。" + "one": "占用 %(size)s 的空间以存储来自 %(rooms)s 个房间的消息,加密消息已安全地在本地缓存,并可以使其显示在搜索结果中。", + "other": "占用 %(size)s 的空间以存储来自 %(rooms)s 个房间的消息,加密消息已安全地在本地缓存,并可以使其显示在搜索结果中。" }, "message_search_failed": "消息搜索初始化失败", "message_search_indexed_messages": "已索引的消息:", "message_search_indexed_rooms": "已索引的房间:", - "message_search_indexing": "正在索引:%(currentRoom)s", - "message_search_indexing_idle": "现在没有为任何房间索引消息。", + "message_search_indexing": "当前正在索引:%(currentRoom)s", + "message_search_indexing_idle": "尚未为任何房间索引消息。", "message_search_intro": "%(brand)s 正在安全地在本地缓存加密消息以使其出现在搜索结果中:", - "message_search_room_progress": "%(totalRooms)s 中之 %(doneRooms)s", + "message_search_pending_rooms": "等待索引的房间:%(pendingRooms)s 个", + "message_search_room_progress": "%(totalRooms)s 个房间中的 %(doneRooms)s 个", "message_search_section": "消息搜索", - "message_search_sleep_time": "消息下载速度。", + "message_search_sleep_time": "消息下载速度", "message_search_space_used": "已使用空间:", - "message_search_unsupported": "%(brand)s缺少安全地在本地缓存加密信息所必须的部件。如果你想实验此功能,请构建一个自定义的带有搜索部件的%(brand)s桌面版。", - "message_search_unsupported_web": "%(brand)s 在浏览器中运行时不能安全地在本地缓存加密信息。请使用%(brand)s 桌面版以使加密信息出现在搜索结果中。", - "record_session_details": "记录客户端名称、版本和url以便在会话管理器里更易识别", - "send_analytics": "发送统计数据", - "strict_encryption": "永不从本会话向未验证的会话发送加密消息" + "message_search_unsupported": "%(brand)s 缺少一些在本地安全缓存加密消息所需的组件。如果你想试用此功能,请构建一个自定义的 %(brand)s 桌面,并添加搜索组件。", + "message_search_unsupported_web": "%(brand)s 在 Web 浏览器中运行时无法安全地在本地缓存加密消息。请使用 %(brand)s Desktop 使加密消息出现在搜索结果中。", + "record_session_details": "在会话管理器中记录客户端名称、版本及 URL 以更易于识别", + "send_analytics": "发送分析数据", + "strict_encryption": "仅向已验证用户发送信息" }, "send_read_receipts": "发送已读回执", "send_read_receipts_unsupported": "你的服务器不支持禁用发送已读回执。", - "send_typing_notifications": "发送正在输入通知", + "send_typing_notifications": "发送键入通知", "sessions": { - "best_security_note": "为获得最佳安全性,请验证您的设备并把您不再信任或使用的设备注销。", + "best_security_note": "为了最佳的安全性请验证此会话,并移除任何你不认识或不再使用的会话。", + "browser": "浏览器", "confirm_sign_out": { - "one": "确认登出此设备", - "other": "确认登出这些设备" + "one": "确认移除此设备", + "other": "确认移除这些设备" }, "confirm_sign_out_body": { - "one": "单击下面的按钮以确认登出此设备。", - "other": "单击下面的按钮以确认登出这些设备。" + "one": "点击以下按钮确认移除此设备", + "other": "点击以下按钮确认移除这些设备" }, "confirm_sign_out_continue": { - "one": "注销设备", - "other": "注销设备" + "one": "移除设备", + "other": "移除设备" }, "confirm_sign_out_sso": { - "one": "确认注销此设备需要使用单点登录来证明您的身份。", - "other": "确认注销这些设备需要使用单点登录来证明你的身份。" + "other": "通过使用单点登录验证你的身份以确认移除这些设备。" }, "current_session": "当前会话", - "details_heading": "会话详情", - "device_unverified_description": "验证此会话或从之登出,以取得最佳安全性和可靠性。", - "device_verified_description": "此会话已准备好进行安全的消息传输。", + "desktop_session": "桌面会话", + "details_heading": "会话详细信息", + "device_unverified_description": "验证或注销此会话以获得最佳安全性与可靠性。", + "device_unverified_description_current": "验证当前会话以增强安全消息传递。", + "device_verified_description": "此会话的安全消息传递已就绪。", + "device_verified_description_current": "当前会话的安全消息传递已就绪。", + "dialog_title": "设置:会话", + "error_pusher_state": "设置推送机制失败", + "error_set_name": "设置会话名称失败", "filter_all": "全部", - "filter_inactive": "不活跃", - "filter_inactive_description": "%(inactiveAgeDays)s天或更久不活跃", + "filter_inactive": "静默", + "filter_inactive_description": "处于静默状态 %(inactiveAgeDays)s 天甚至更长时间", "filter_label": "筛选设备", - "filter_unverified_description": "尚未准备好安全通信", - "filter_verified_description": "准备好进行安全通信了", - "inactive_days": "%(inactiveAgeDays)s+天不活跃", - "inactive_sessions": "不活跃的会话", - "ip": "IP地址", - "last_activity": "上次活动", - "no_inactive_sessions": "未找到不活跃的会话。", + "filter_unverified_description": "安全消息传递未就绪", + "filter_verified_description": "安全消息传递已就绪", + "hide_details": "隐藏详细信息", + "inactive_days": "已持续静默至少 %(inactiveAgeDays)s 天", + "inactive_sessions": "静默会话", + "inactive_sessions_explainer_1": "静默会话是指你一段时间内未使用,但仍会继续接收加密密钥的会话。", + "inactive_sessions_explainer_2": "移除静默会话可提高安全性与性能,并让你更容易识别新的会话是否可疑。", + "inactive_sessions_list_description": "考虑移除不再使用的旧会话(%(inactiveAgeDays)s 天或更早)。", + "ip": "IP 地址", + "last_activity": "最后活跃于", + "manage": "管理此会话", + "mobile_session": "移动端会话", + "n_sessions_selected": { + "one": "已选择 %(count)s 个会话", + "other": "已选择 %(count)s 个会话" + }, + "no_inactive_sessions": "未找到静默的会话。", "no_sessions": "未找到会话。", "no_unverified_sessions": "未找到未验证的会话。", "no_verified_sessions": "未找到已验证的会话。", - "other_sessions_heading": "其他会话", + "os": "操作系统", + "other_sessions_heading": "其它会话", + "push_heading": "推送通知", + "push_subheading": "允许此会话接收通知推送", + "push_toggle": "切换此会话的推送通知。", + "rename_form_caption": "请注意,会话名称也对与你通信的人可见。", "rename_form_heading": "重命名会话", + "rename_form_learn_more": "正在重命名会话", + "rename_form_learn_more_description_1": "你加入的私聊与房间中的其他用户可以查看你的完整会话列表。", + "rename_form_learn_more_description_2": "这不仅能使其确信自己确实在与你通话,还能看到你在此处输入的会话名称。", "security_recommendations": "安全建议", - "security_recommendations_description": "按照以下建议来提高您的帐户安全性。", + "security_recommendations_description": "通过以下建议增强账户安全性", "session_id": "会话 ID", + "show_details": "显示详细信息", + "sign_in_with_qr": "关联新设备", + "sign_in_with_qr_button": "显示二维码", + "sign_in_with_qr_description": "使用二维码登录到另一设备并设置安全消息传递。", + "sign_in_with_qr_unsupported": "账户提供者不支持", + "sign_out": "移除此会话", + "sign_out_all_other_sessions": "移除所有其它会话(%(otherSessionsCount)s)", "sign_out_confirm_description": { - "other": "你确定要从这 %(count)s 个会话中退出吗?" + "one": "你确定要移除 %(count)s 个会话?", + "other": "你确定要移除 %(count)s 个会话?" + }, + "sign_out_n_sessions": { + "one": "移除 %(count)s 个会话", + "other": "移除 %(count)s 个会话" }, "title": "会话", + "unknown_session": "未知会话类型", "unverified_session": "未验证的会话", + "unverified_session_explainer_1": "此会话不支持加密,因此无法验证。", + "unverified_session_explainer_2": "使用此会话时,你将无法加入启用加密的房间。", + "unverified_session_explainer_3": "为了获得最佳的安全性与隐私性,建议使用支持加密的 Matrix 客户端。", "unverified_sessions": "未验证的会话", - "unverified_sessions_list_description": "验证你的会话以增强消息传输的安全性,或从那些你不认识或不再使用的会话登出。", + "unverified_sessions_explainer_1": "未验证的会话是指已使用你的凭据登录但尚未通过你的数字身份确认的会话。", + "unverified_sessions_explainer_2": "你应特别确认自己能够识别这些会话,因为它们可能代表你的账户被未经授权使用。", + "unverified_sessions_list_description": "验证此会话以强化安全消息传递,或移除你不认识或不再使用的会话。", + "url": "URL", "verified_session": "已验证的会话", "verified_sessions": "已验证的会话", - "verified_sessions_list_description": "为了最佳安全性,请从任何不认识或不再使用的会话登出。", - "verify_session": "验证会话" + "verified_sessions_explainer_1": "已验证会话是指已经过你的数字身份确认的会话。", + "verified_sessions_explainer_2": "这意味着你拥有解锁加密消息所需的所有密钥,并向其他用户确认你信任此会话。", + "verified_sessions_list_description": "为了最佳的安全性,请移除任何你不认识或不再使用的会话。", + "verify_session": "验证会话", + "web_session": "Web 会话" }, - "show_avatar_changes": "显示个人头像变更", - "show_breadcrumbs": "在房间列表上方显示最近浏览过的房间的快捷方式", - "show_chat_effects": "显示聊天特效(如收到五彩纸屑时的动画效果)", - "show_displayname_changes": "显示显示名称更改", - "show_join_leave": "显示加入/离开消息(邀请/移除/封禁不受影响)", + "show_avatar_changes": "显示个人资料图像更改", + "show_breadcrumbs": "在房间列表之上显示最近访问的房间的捷径", + "show_chat_effects": "显示聊天特效(例如当收到“五彩纸屑”时)", + "show_displayname_changes": "显示用户的“显示名称更改”", + "show_join_leave": "显示加入及离开消息(邀请、移除及封禁不受影响)", + "show_message_previews": "显示消息预览", "show_nsfw_content": "显示 NSFW 内容", - "show_read_receipts": "显示其他用户发送的已读回执", - "show_redaction_placeholder": "已移除的消息显示为一个占位符", + "show_read_receipts": "显示其他用户的已读回执", + "show_redaction_placeholder": "为已被移除的消息显示占位符", "show_stickers_button": "显示贴纸按钮", - "show_typing_notifications": "显示正在输入通知", + "show_typing_notifications": "显示用户的键入通知", + "showbold": "在房间列表上显示所有活动(用于未读消息的圆点与数字)", "sidebar": { - "metaspaces_favourites_description": "将所有你最爱的房间和人集中在一处。", + "dialog_title": "设置:边栏", + "metaspaces_favourites_description": "集中所有设为收藏的房间与人员分组在一处。", "metaspaces_home_all_rooms": "显示所有房间", - "metaspaces_home_all_rooms_description": "在主页展示你所有的房间,即使它们是在一个空间里。", - "metaspaces_home_description": "对于了解所有事情的概况来说,主页很有用。", - "metaspaces_orphans": "空间之外的房间", - "metaspaces_orphans_description": "将所有你那些不属于某个空间的房间集中一处。", - "metaspaces_people_description": "将你所有的联系人集中一处。", + "metaspaces_home_all_rooms_description": "在主页显示所有房间,即使它们位于不同的空间。", + "metaspaces_home_description": "主页有助于概览所有对话。", + "metaspaces_orphans": "空间外的房间", + "metaspaces_orphans_description": "集中所有不属于任何空间的房间在一处。", + "metaspaces_people_description": "集中所有人员在一处。", "metaspaces_subsection": "要显示的空间", - "title": "侧边栏" + "metaspaces_video_rooms": "视频房间与会议", + "metaspaces_video_rooms_description": "集中所有私有视频房间与会议在一处。", + "metaspaces_video_rooms_description_invite_extension": "在会议中,你可以邀请 Matrix 生态以外的人。", + "spaces_explainer": "空间是对房间与人员分组的方式,除了你所在的空间,还可以使用一些预置空间。", + "title": "边栏" }, - "use_12_hour_format": "使用 12 小时制显示时间戳 (下午 2:30)", - "use_command_enter_send_message": "使用 Command + Enter 发送消息", - "use_command_f_search": "使用 Command + F 搜索时间线", - "use_control_enter_send_message": "使用Ctrl + Enter发送消息", - "use_control_f_search": "使用 Ctrl + F 搜索时间线", + "start_automatically": { + "disabled": "否", + "enabled": "是", + "label": "登录时启动 %(brand)s", + "minimised": "最小化" + }, + "tac_only_notifications": "仅在消息列中枢显示通知", + "use_12_hour_format": "以 12 小时制显示时间戳(例如:下午 2:30)", + "use_command_enter_send_message": "按 Command + Enter 发送消息", + "use_command_f_search": "允许按 Command + F 搜索时间线", + "use_control_enter_send_message": "允许按 Ctrl + Enter 发送消息", + "use_control_f_search": "允许按 Ctrl + F 搜索时间线", "voip": { - "allow_p2p": "允许1:1通话的点对点", - "allow_p2p_description": "启用后,对方可能能看到你的IP地址", + "allow_p2p": "允许点对点进行一对一通话", + "allow_p2p_description": "启用此项后, 第三方可能会看到你的 IP 地址", "audio_input_empty": "未检测到麦克风", "audio_output": "音频输出", - "audio_output_empty": "未检测到可用的音频输出方式", - "auto_gain_control": "自动获得控制权", + "audio_output_empty": "未检测到音频输出", + "auto_gain_control": "自动增益控制", "connection_section": "连接", + "dialog_title": "设置:语音与视频", "echo_cancellation": "回声消除", - "enable_fallback_ice_server": "允许使用官方的ICE辅助服务器 (%(server)s)", - "enable_fallback_ice_server_description": "仅当你的服务器不提供时才会使用。你的IP地址在通话期间会被知晓。", + "echo_cancellation_description": "在通话期间移除回声。此设置亦将应用于 Element Call。", + "enable_fallback_ice_server": "允许备选的通话辅助服务器", + "enable_fallback_ice_server_description": "仅用于你所在的主服务器不提供时。你的 IP 地址会在通话期间被分享。", "mirror_local_feed": "镜像本地视频源", - "missing_permissions_prompt": "缺少媒体权限,点击下面的按钮以请求权限。", - "noise_suppression": "噪音抑制", + "missing_permissions_prompt": "缺少媒体权限,点击按钮以请求。", + "noise_suppression": "降噪", + "noise_suppression_description": "在通话期间降低背景噪音。此设置亦将应用于 Element Call。", "request_permissions": "请求媒体权限", - "title": "语音和视频", + "title": "语音与视频", "video_input_empty": "未检测到摄像头", "video_section": "视频设置", - "voice_agc": "自动调整话筒音量", + "voice_agc": "自动调整麦克风音量", "voice_processing": "语音处理", "voice_section": "语音设置" }, @@ -2248,623 +3056,678 @@ "warning": "警告:" }, "share": { - "permalink_message": "选中消息的链接", - "permalink_most_recent": "最新消息的链接", - "title_message": "分享房间消息", + "link_copied": "链接已复制", + "permalink_message": "链接可指向所选消息", + "permalink_most_recent": "链接到最近消息", + "share_call": "会议邀请链接", + "share_call_subtitle": "外部用户无需 Matrix 账户即可加入通话的链接:", + "title_link": "分享链接", + "title_message": "分享房间中的消息", "title_room": "分享房间", "title_user": "分享用户" }, "slash_command": { - "addwidget": "通过URL添加自定义挂件到房间", - "addwidget_iframe_missing_src": "iframe无src属性", - "addwidget_invalid_protocol": "请提供一个 https:// 或 http:// 挂件URL", - "addwidget_missing_url": "请提供一个挂件URL或嵌入代码", - "addwidget_no_permissions": "你无法修改此房间的插件。", - "ban": "按照 ID 封禁用户", - "category_actions": "动作", + "addwidget": "添加自定义小部件", + "addwidget_iframe_missing_src": "“iframe”没有“src”属性", + "addwidget_invalid_protocol": "请提供“http://”或“https://”开头的小部件 URL", + "addwidget_missing_url": "请提供小部件 URL 或其嵌入式代码", + "addwidget_no_permissions": "你在此房间无法修改小部件。", + "ban": "使用指定 ID 封禁用户", + "category_actions": "操作", "category_admin": "管理员", "category_advanced": "高级", - "category_effects": "效果", + "category_effects": "特效", "category_messages": "消息", - "category_other": "其他", - "command_error": "命令错误", - "converttodm": "将此房间会话转化为私聊会话", - "converttoroom": "将此私聊会话转化为房间会话", + "category_other": "其它", + "command_error": "指令出错", + "converttodm": "转换房间为私聊", + "converttoroom": "转换私聊到房间", "could_not_find_room": "无法找到房间", - "deop": "按照 ID 取消特定用户的管理员权限", - "devtools": "打开开发者工具窗口", - "discardsession": "强制丢弃加密房间中的当前出站群组会话", - "error_invalid_rendering_type": "命令错误:无法找到渲染类型(%(renderingType)s)", - "error_invalid_runfn": "命令错误:无法处理斜杠命令。", - "help": "显示指令清单与其描述和用法", + "deop": "通过指定的 ID 降权用户", + "devtools": "打开开发者工具对话框", + "discardsession": "强制丢弃加密房间中当前的出站组会话", + "error_invalid_rendering_type": "指令错误:无法找到修饰类型(%(renderingType)s)", + "error_invalid_room": "指令执行失败: 无法找到房间(%(roomId)s)", + "error_invalid_runfn": "指令错误:无法处理斜杠指令。", + "error_invalid_user_in_room": "在房间中无法找到用户", + "help": "将指令与其对应的用法、说明显示为一个列表", "help_dialog_title": "命令帮助", - "holdcall": "挂起当前房间的通话", - "html": "以 html 格式发送消息,不将其作为 markdown 处理", - "ignore": "忽略用户,隐藏他们发送的消息", - "ignore_dialog_description": "你忽略了 %(userId)s", + "holdcall": "将当前房间中的通话置于保持状态", + "html": "作为 HTML 发送消息,而不是将其解释为 Markdown", + "ignore": "忽略用户以为你隐藏其消息", + "ignore_dialog_description": "你正在忽略 %(userId)s", "ignore_dialog_title": "已忽略的用户", - "invite": "邀请指定ID的用户到当前房间", - "invite_3pid_needs_is_error": "使用身份服务器以通过电子邮件邀请其他用户。在设置中进行管理。", + "invite": "通过指定的 ID 邀请用户到当前房间", + "invite_3pid_needs_is_error": "使用身份服务器以通过邮件发出邀请。在设置中进行管理。", "invite_3pid_use_default_is_title": "使用身份服务器", - "invite_3pid_use_default_is_title_description": "使用身份服务器以通过电子邮件邀请其他用户。单击继续以使用默认身份服务器(%(defaultIdentityServerName)s),或在设置中进行管理。", - "invite_failed": "用户(%(user)s)最终未被邀请到%(roomId)s,但邀请工具没给出错误", - "join": "使用指定地址加入房间", - "jumptodate": "跳转到时间线中的给定日期", - "jumptodate_invalid_input": "我们无法理解给定日期 (%(inputDate)s)。尝试使用如下格式 YYYY-MM-DD。", - "lenny": "在纯文本消息开头添加 ( ͡° ͜ʖ ͡°)", + "invite_3pid_use_default_is_title_description": "使用身份服务器通过邮件地址邀请。点击“继续”以使用默认身份服务器 (%(defaultIdentityServerName)s),或在“设置”中管理。", + "invite_failed": "用户(%(user)s)最终未被邀请加入 %(roomId)s,但邀请程序未显示任何错误。", + "join": "通过指定的房间地址加入", + "jumptodate": "在时间线中跳转到指定日期", + "jumptodate_invalid_input": "我们无法理解指定的日期 (%(inputDate)s)。尝试使用 YYYY-MM-DD 格式。", + "lenny": "附加“( ͡° ͜ʖ ͡°)”到纯文本消息", + "manual_device_verification_confirm_description": "这将允许其它设备以你的身份发送和接收消息。如果有人告诉你在此处粘贴内容,你很可能被诈骗!你确定要验证此设备?", + "manual_device_verification_confirm_title": "警告:手动设备验证", "me": "显示操作", - "msg": "向指定用户发消息", - "myavatar": "在所有房间中更新您的头像", - "myroomavatar": "仅在当前房间中更改您的头像", - "myroomnick": "仅更改当前房间中的显示昵称", - "nick": "修改显示昵称", - "no_active_call": "此房间未有活跃中的通话", - "op": "定义一名用户的权力级别", + "msg": "向指定用户发送信息", + "myavatar": "更改个人资料图像(应用于所有房间)", + "myroomavatar": "仅更改我在当前房间内的个人资料图像", + "myroomnick": "仅更改我在当前房间内的显示名称", + "nick": "更改显示名称", + "no_active_call": "此房间没有活跃的通话", + "op": "定义用户的权力值", "part_unknown_alias": "无法识别的房间地址:%(roomAlias)s", - "plain": "以纯文本形式发送消息,不将其作为 markdown 处理", - "query": "与指定用户发起聊天", - "query_not_found_phone_number": "未能找到与此手机号码关联的 Matrix ID", - "rageshake": "发送带日志的错误报告", - "rainbow": "此消息以彩虹色进行渲染", - "rainbowme": "以彩虹色发送给定表情符号", - "remove": "将给定 ID 的用户移除此房间", - "roomavatar": "更改当前房间头像", + "plain": "作为纯文本发送消息,而不是将其解释为 Markdown", + "query": "打开与指定用户的聊天", + "query_not_found_phone_number": "无法找到与此电话号码对应的 Matrix ID", + "rageshake": "通过日志发送 Bug 报告", + "rainbow": "发送一个渲染为彩虹色的消息", + "rainbowme": "发送一个带有我的显示名称与渲染为彩虹色的消息", + "remove": "通过指定的 ID 从当前房间移除用户", + "roomavatar": "更改当前房间的头像", "roomname": "设置房间名称", "server_error": "服务器错误", - "server_error_detail": "服务器不可用、超载或其他东西出错了。", - "shrug": "在纯文本消息开头添加 ¯\\_(ツ)_/¯", - "spoiler": "此消息包含剧透", - "tableflip": "在纯文本消息开头添加 (╯°□°)╯︵ ┻━┻", - "topic": "获取或设置房间话题", - "topic_none": "此房间没有话题。", - "topic_room_error": "获取房间话题失败:无法找到房间(%(roomId)s)", - "unban": "按照 ID 解封用户", - "unflip": "在纯文本消息开头添加 ┬──┬ ノ( ゜-゜ノ)", - "unholdcall": "解除挂起当前房间的通话", - "unignore": "解除忽略用户,显示他们的消息", - "unignore_dialog_description": "你不再忽视 %(userId)s", - "unignore_dialog_title": "未忽略的用户", - "unknown_command": "未知命令", + "server_error_detail": "服务器不可用、超载或出现其它问题。", + "shrug": "附加“ˉ\\_(ツ)_/ˉ”到纯文本消息", + "spoiler": "作为剧透发送指定消息", + "tableflip": "附加“(╯°□°)╯︵ ┻━┻”到纯文本消息", + "topic": "获取或设置房间主题", + "topic_none": "此房间没有主题", + "topic_room_error": "获取房间主题失败:无法找到房间(%(roomId)s)", + "unban": "使用指定的 ID 解封用户", + "unflip": "附加“┬──┬ ノ( ゜-゜ノ)”到纯文本消息", + "unholdcall": "取消当前房间中的通话保持状态", + "unignore": "解除忽略用户,向你显示其未来的消息", + "unignore_dialog_description": "你不再忽略 %(userId)s", + "unignore_dialog_title": "已解除忽略的用户", + "unknown_command": "未知指令", "unknown_command_button": "作为消息发送", - "unknown_command_detail": "未识别的命令:%(commandText)s", - "unknown_command_help": "你可以使用 /help 列出可用命令。你是否要将其作为消息发送?", - "unknown_command_hint": "提示:以 // 开始你的消息来使其以一个斜杠开始。", + "unknown_command_detail": "无法识别的指令:%(commandText)s", + "unknown_command_help": "你可以使用 /help 列出可用的指令。是否将此文本作为消息发送?", + "unknown_command_hint": "提示:请以 / 开头。", "upgraderoom": "将房间升级到新版本", - "upgraderoom_permission_error": "你没有权限使用此命令。", + "upgraderoom_permission_error": "你无权使用此命令。", "usage": "用法", - "verify": "验证用户、会话和公钥元组", - "whois": "显示关于用户的信息" + "verify": "手动验证一个你拥有的设备", + "view": "通过指定的地址查看房间", + "whois": "显示用户信息" }, + "sliding_sync_legacy_no_longer_supported": "旧版滑动同步不再受支持:请重新登录以启用新的滑动同步标志", "space": { "add_existing_room_space": { - "create": "想要添加一个新的房间吗?", + "create": "想要添加新房间?", "create_prompt": "创建新房间", "dm_heading": "私聊", - "error_heading": "并非所有选中的都被添加", + "error_heading": "并非所有选定项都已添加", "progress_text": { - "one": "正在新增房间……", - "other": "正在新增房间……(%(count)s 中的第 %(progress)s 个)" + "one": "正在添加房间…", + "other": "正在添加房间…(%(count)s 中的 %(progress)s 个)" }, "space_dropdown_label": "空间选择", "space_dropdown_title": "添加现有房间", - "subspace_moved_note": "新增空间已移动。" + "subspace_moved_note": "添加的空间已移动。" }, "add_existing_subspace": { "create_button": "创建新空间", - "create_prompt": "想要添加一个新空间?", + "create_prompt": "添加一个新空间?", "filter_placeholder": "搜索空间", - "space_dropdown_title": "增加现有的空间" + "space_dropdown_title": "添加现有空间" }, "context_menu": { - "devtools_open_timeline": "查看房间时间线(开发工具)", - "explore": "探索房间", - "home": "空间首页", - "manage_and_explore": "管理并探索房间", + "devtools_open_timeline": "查看房间时间线(开发者工具)", + "explore": "浏览房间", + "home": "空间主页", + "manage_and_explore": "房间浏览与管理", "options": "空间选项" }, - "failed_load_rooms": "加载房间列表失败。", - "failed_remove_rooms": "无法移除某些房间。请稍后再试", - "incompatible_server_hierarchy": "你的服务器不支持显示空间层次结构。", - "invite": "邀请人们", - "invite_description": "使用邮箱或者用户名邀请", + "failed_load_rooms": "载入房间列表失败。", + "failed_remove_rooms": "某些房间移除失败。请稍后再试。", + "incompatible_server_hierarchy": "你的服务器不支持显示空间层级。", + "invite": "邀请人员", + "invite_description": "通过电话号码或邮件地址邀请", "invite_link": "分享邀请链接", - "joining_space": "加入中", + "joining_space": "正在加入", "landing_welcome": "欢迎来到 ", "leave_dialog_action": "离开空间", "leave_dialog_description": "你即将离开 。", - "leave_dialog_only_admin_room_warning": "你是某些要离开的房间或空间的唯一管理员。离开将使它们没有任何管理员。", - "leave_dialog_only_admin_warning": "你是此空间的唯一管理员。离开它将意味着没有人可以控制它。", + "leave_dialog_only_admin_room_warning": "对于你想要离开的某些房间或空间,你是其唯一的管理员。离开这些房间或空间将使其失去任何管理角色。", + "leave_dialog_only_admin_warning": "你是此空间的唯一管理员。离开后,任何人都无法控制它。", "leave_dialog_option_all": "离开所有房间", - "leave_dialog_option_intro": "你想俩开此空间内的房间吗?", - "leave_dialog_option_none": "不离开任何房间", - "leave_dialog_option_specific": "离开一些房间", - "leave_dialog_public_rejoin_warning": "除非你被重新邀请,否则你将无法重新加入。", + "leave_dialog_option_intro": "你确定要离开此空间中的房间?", + "leave_dialog_option_none": "不要离开任何房间", + "leave_dialog_option_specific": "离开房间", + "leave_dialog_public_rejoin_warning": "除非重新被邀请,否则你将无法重新加入。", "leave_dialog_title": "离开 %(spaceName)s", - "mark_suggested": "标记为建议", - "no_search_result_hint": "你可能要尝试其他搜索或检查是否有错别字。", + "mark_suggested": "设为建议", + "no_search_result_hint": "你可能需要尝试不同的搜索或检查错别字。", "preferences": { "sections_section": "要显示的部分", - "show_people_in_space": "将您与该空间的成员的聊天进行分组。关闭这个后你将无法在 %(spaceName)s 内看到这些聊天。" + "show_people_in_space": "这会将你的聊天与此空间的成员分组。关闭此项将在 %(spaceName)s 视图中隐藏这些聊天。" }, "room_filter_placeholder": "搜索房间", "search_children": "搜索 %(spaceName)s", - "search_placeholder": "搜索名称和描述", - "select_room_below": "首先选择一个房间", - "share_public": "分享你的公共空间", + "search_placeholder": "搜索名称与描述", + "select_room_below": "首选以下房间", + "share_public": "分享公共空间", "suggested": "建议", - "suggested_tooltip": "此房间很适合加入", + "suggested_tooltip": "建议加入此房间", "title_when_query_available": "结果", "title_when_query_unavailable": "房间与空间", - "unmark_suggested": "标记为不建议", + "unmark_suggested": "设为不建议", "user_lacks_permission": "你没有权限" }, "space_settings": { - "title": "设置 - %(spaceName)s" + "title": "设置:%(spaceName)s" }, "spaces": { - "error_no_permission_add_room": "你没有权限添加房间至此空间", - "error_no_permission_add_space": "你没有权限向此空间添加空间", - "error_no_permission_create_room": "你没有权限在此空间内创建新的房间", - "error_no_permission_invite": "你无权邀请他人加入此空间" + "error_no_permission_add_room": "你无权在此空间添加房间", + "error_no_permission_add_space": "你无权在此空间中添加空间", + "error_no_permission_create_room": "你无权在此空间创建新房间", + "error_no_permission_invite": "你无权邀请人员访问此空间" }, "spotlight": { "public_rooms": { - "network_dropdown_add_dialog_description": "输入你想探索的新服务器的服务器名。", - "network_dropdown_add_dialog_placeholder": "服务器名", - "network_dropdown_add_dialog_title": "添加新服务器", - "network_dropdown_add_server_option": "添加新的服务器…", - "network_dropdown_available_invalid": "找不到此服务器或其房间列表", + "network_dropdown_add_dialog_description": "输入要浏览的新服务器名称。", + "network_dropdown_add_dialog_placeholder": "服务器名称", + "network_dropdown_add_dialog_title": "添加新服务器…", + "network_dropdown_add_server_option": "添加新服务器…", + "network_dropdown_available_invalid": "无法找到此服务器或其房间列表", "network_dropdown_available_invalid_forbidden": "你不被允许查看此服务器的房间列表", - "network_dropdown_available_valid": "看着不错", + "network_dropdown_available_valid": "良好", "network_dropdown_remove_server_adornment": "移除服务器“%(roomServer)s”", - "network_dropdown_required_invalid": "请输入服务器名", - "network_dropdown_selected_label": "显示:Matrix房间", - "network_dropdown_selected_label_instance": "显示:%(instance)s房间(%(server)s)", + "network_dropdown_required_invalid": "输入服务器名称", + "network_dropdown_selected_label": "显示:Matrix 房间", + "network_dropdown_selected_label_instance": "显示 %(instance)s 房间(%(server)s)", "network_dropdown_your_server_description": "你的服务器" } }, "spotlight_dialog": { - "cant_find_person_helpful_hint": "若你无法看到你正在查找的人,给他们发送你的邀请链接。", - "cant_find_room_helpful_hint": "若你找不到要找的房间,请请求邀请或创建新房间。", + "cant_find_person_helpful_hint": "如果你看不到要找的人员,请复制并向其发送以下邀请链接。", + "cant_find_room_helpful_hint": "如果你找不到所需的房间,请向其申请加入或创建新房间。", "copy_link_text": "复制邀请链接", "count_of_members": { - "one": "%(count)s个成员", - "other": "%(count)s个成员" + "one": "%(count)s 位成员", + "other": "%(count)s 个成员" }, "create_new_room_button": "创建新房间", - "failed_querying_public_rooms": "查询公开房间失败", - "group_chat_section_title": "其他选项", - "heading_with_query": "使用 \"%(query)s\" 来搜索", - "heading_without_query": "搜索", - "join_button_text": "加入%(roomAddress)s", - "keyboard_scroll_hint": "用来滚动", - "other_rooms_in_space": "%(spaceName)s 中的其他房间", + "failed_querying_public_rooms": "公共房间查询失败", + "failed_querying_public_spaces": "公共空间查询失败", + "group_chat_section_title": "其它选项", + "heading_with_query": "使用“%(query)s”搜索", + "heading_without_query": "搜索类型", + "join_button_text": "加入 %(roomAddress)s", + "keyboard_scroll_hint": "使用 滚动", + "messages_label": "消息", + "other_rooms_in_space": "%(spaceName)s 中的其它房间", "public_rooms_label": "公共房间", "public_spaces_label": "公共空间", - "recent_searches_section_title": "最近的搜索", + "recent_searches_section_title": "最近搜索", "recently_viewed_section_title": "最近查看", - "remove_filter": "移除%(filter)s搜索过滤条件", - "result_may_be_hidden_privacy_warning": "为保护隐私,一些结果可能被隐藏", - "result_may_be_hidden_warning": "一些结果可能被隐藏", - "search_dialog": "搜索对话", + "remove_filter": "移除筛选 %(filter)s 的搜索", + "result_may_be_hidden_privacy_warning": "由于隐私原因,某些结果可能被隐藏。", + "result_may_be_hidden_warning": "某些结果可能被隐藏", + "search_dialog": "搜索对话框", "spaces_title": "你所在的空间", - "start_group_chat_button": "发起群聊天" + "start_group_chat_button": "开始群聊" }, "stickers": { - "empty": "你目前未启用任何贴纸包", + "empty": "你当前未启用任何贴纸包", "empty_add_prompt": "立即添加" }, "terms": { "column_document": "文档", "column_service": "服务", - "column_summary": "总结", - "identity_server_no_terms_description_1": "此操作需要访问默认的身份服务器 以验证邮箱或电话号码,但此服务器无任何服务条款。", - "identity_server_no_terms_description_2": "只有在你信任服务器所有者后才能继续。", - "identity_server_no_terms_title": "身份服务器无服务条款", + "column_summary": "摘要", + "identity_server_no_terms_description_1": "此操作需要访问默认身份服务器 以验证邮件地址或电话号码,但该服务器没有任何服务条款。", + "identity_server_no_terms_description_2": "只有在你信任服务器所有者的情况下才能继续。", + "identity_server_no_terms_title": "身份服务器暂无服务条款", "inline_intro_text": "接受 以继续:", - "integration_manager": "使用机器人、桥接、挂件和贴纸包", - "intro": "要继续,你需要接受此服务协议。", - "summary_identity_server_1": "通过电话或邮箱寻找别人", - "summary_identity_server_2": "通过电话或邮箱被寻找", + "integration_manager": "使用机器人、桥接器、小部件与贴纸包", + "intro": "若要继续,你必须接受此服务的条款。", + "summary_identity_server_1": "通过电话号码或邮件地址查找", + "summary_identity_server_2": "通过电话号码或邮件地址查找", "tac_button": "浏览条款与要求", - "tac_description": "若要继续使用家服务器 %(homeserverDomain)s,你必须浏览并同意我们的条款与要求。", - "tac_title": "条款与要求", - "tos": "服务协议" + "tac_description": "要继续使用主服务器 %(homeserverDomain)s,你必须查看并同意我们的条款和条件。", + "tac_title": "条款与条件", + "tos": "服务条款" }, "theme": { - "light_high_contrast": "浅色高对比", - "match_system": "匹配系统" + "light_high_contrast": "高对比度浅色", + "match_system": "跟随系统" }, - "thread_view_back_action_label": "返回消息列", + "thread_view_back_action_label": "回到消息列", "threads": { "all_threads": "所有消息列", "all_threads_description": "显示当前房间的所有消息列", "count_of_reply": { - "one": "%(count)s 条回复", - "other": "%(count)s 条回复" + "one": "%(count)s 个回复", + "other": "%(count)s 个回复" }, + "empty_description": "将鼠标指针悬停在某个消息上并点击“%(replyInThread)s”。", + "empty_title": "消息列有助于持续并跟进题内的对话。", + "mark_all_read": "全部设为已读", "my_threads": "我的消息列", - "my_threads_description": "显示您参与的所有消息列", + "my_threads_description": "显示你参与的所有消息列", "open_thread": "打开消息列", "show_thread_filter": "显示:" }, + "threads_activity_centre": { + "header": "消息列活动", + "no_rooms_with_threads_notifs": "暂无包含消息列通知的房间。", + "no_rooms_with_unread_threads": "暂无包含未读消息列的房间。" + }, "time": { "date_at_time": "%(date)s 的 %(time)s", - "hours_minutes_seconds_left": "剩余%(hours)s小时%(minutes)s分钟%(seconds)s秒", - "left": "剩余%(timeRemaining)s", - "minutes_seconds_left": "剩余%(minutes)s分钟%(seconds)s秒", - "seconds_left": "剩余 %(seconds)s 秒", + "hours_minutes_seconds_left": "已离开 %(hours)s 小时 %(minutes)s 分钟 %(seconds)s 秒", + "left": "剩余 %(timeRemaining)s", + "minutes_seconds_left": "已离开 %(minutes)s 分钟 %(seconds)ss 秒", + "seconds_left": "已离开 %(seconds)ss 秒", "short_days": "%(value)s 天", - "short_days_hours_minutes_seconds": "%(days)s天%(hours)s小时%(minutes)s分钟%(seconds)s秒", + "short_days_hours_minutes_seconds": "%(days)s 天 %(hours)s 小时 %(minutes)s 分钟 %(seconds)s 秒", "short_hours": "%(value)s 小时", - "short_hours_minutes_seconds": "%(hours)s小时%(minutes)s分钟%(seconds)s秒", + "short_hours_minutes_seconds": "%(hours)s 小时 %(minutes)s 分钟 %(seconds)ss 秒", "short_minutes": "%(value)s 分钟", - "short_minutes_seconds": "%(minutes)s分钟%(seconds)s秒", + "short_minutes_seconds": "%(minutes)s 分钟 %(seconds)s 秒", "short_seconds": "%(value)s 秒" }, "timeline": { "context_menu": { "collapse_reply_thread": "折叠回复消息列", - "external_url": "源网址", + "external_url": "源 URL", "open_in_osm": "在 OpenStreetMap 中打开", "report": "举报", - "resent_unsent_reactions": "重新发送%(unsentCount)s个反应", + "resent_unsent_reactions": "重新发送 %(unsentCount)s 的反应", "show_url_preview": "显示预览", "view_related_event": "查看相关事件", "view_source": "查看源代码" }, - "creation_summary_dm": "%(creator)s 创建了此私聊。", + "creation_summary_dm": "%(creator)s 创建了私聊。", "creation_summary_room": "%(creator)s 创建并配置了此房间。", - "download_action_downloading": "下载中", - "edits": { - "tooltip_label": "编辑于 %(date)s。点击以查看编辑历史。", - "tooltip_sub": "点击查看编辑历史", - "tooltip_title": "编辑于 %(date)s" + "decryption_failure": { + "sender_identity_previously_verified": "发件人已验证的身份已被重置", + "unable_to_decrypt": "无法解密消息" }, - "error_no_renderer": "无法显示此事件", + "disambiguated_profile": "%(displayName)s(%(matrixId)s)", + "download_action_downloading": "下载中", + "download_failed": "下载失败", + "download_failed_description": "下载此文件时出错", + "e2e_state": "端到端加密状态", + "edits": { + "tooltip_label": "编辑于 %(date)s,点击查看编辑历史。", + "tooltip_sub": "点击查看编辑历史", + "tooltip_title": "最后编辑于 %(date)s" + }, + "error_no_renderer": "此事件无法显示", "error_rendering_message": "无法加载此消息", - "historical_messages_unavailable": "你不能查看更早的消息", + "historical_messages_unavailable": "你无法查看之前的消息", + "in_room_name": "位于 %(room)s", "io.element.widgets.layout": "%(senderName)s 更新了房间布局", + "late_event_separator": "原定发送于 %(dateTime)s", "load_error": { - "no_permission": "尝试了加载此房间时间线上的特定点,但你没有查看相关消息的权限。", - "title": "加载时间线位置失败", - "unable_to_find": "尝试加载此房间的时间线的特定时间点,但是无法找到。" + "no_permission": "尝试加载此房间时间线中的特定时间点,但你无权查看相关消息。", + "title": "时间线位置加载失败", + "unable_to_find": "尝试加载此房间时间线中的特定时间点,但无法找到。" }, "m.audio": { "error_downloading_audio": "下载音频时出错", "error_processing_audio": "处理音频消息时出错", - "error_processing_voice_message": "处理语音消息时发生错误" + "error_processing_voice_message": "处理语音消息时出错" }, "m.beacon_info": { "view_live_location": "查看实时位置" }, "m.call": { - "video_call_started": "%(roomName)s里的视频通话开始了。", - "video_call_started_unsupported": "%(roomName)s里的视频通话开始了。(此浏览器不支持)" + "video_call_ended": "视频通话已结束", + "video_call_started": "在 %(roomName)s 开始视频通话。", + "video_call_started_text": "%(name)s 已开始视频通话", + "video_call_started_unsupported": "已在 %(roomName)s 开始视频通话。(此浏览器不支持)" }, "m.call.hangup": { - "dm": "通话结束" + "dm": "通话已结束" }, "m.call.invite": { + "answered_elsewhere": "已在别处接听", "call_back_prompt": "回拨", - "declined": "拒绝通话", + "declined": "来电被拒接", "failed_connect_media": "无法连接媒体", "failed_connection": "连接失败", - "failed_opponent_media": "他们的设备无法启动摄像头或麦克风", + "failed_opponent_media": "其设备无法开启摄像头或麦克风", "missed_call": "未接来电", - "no_answer": "无响应", - "unknown_error": "出现未知错误", + "no_answer": "未接听", + "unknown_error": "发生未知错误", "unknown_failure": "未知错误:%(reason)s", "unknown_state": "通话处于未知状态!", - "video_call": "%(senderName)s 发起了视频通话。", - "video_call_unsupported": "%(senderName)s 发起了视频通话。(此浏览器不支持)", - "voice_call": "%(senderName)s 发起了语音通话。", - "voice_call_unsupported": "%(senderName)s 发起了语音通话。(此浏览器不支持)" + "video_call": "来自 %(senderName)s 的视频通话。", + "video_call_unsupported": "%(senderName)s 已开始视频通话。(此浏览器不支持)", + "voice_call": "来自 %(senderName)s 的语音通话。", + "voice_call_unsupported": "%(senderName)s 已开始语音通话。(此浏览器不支持)" }, "m.file": { "error_decrypting": "解密附件时出错" }, "m.image": { + "error": "由于错误无法显示图片", "error_decrypting": "解密图像时出错", - "sent": "%(senderDisplayName)s 发送了一张图片。", + "error_downloading": "下载图像时出错", + "sent": "%(senderDisplayName)s 发送了一个图像。", "show_image": "显示图像" }, "m.key.verification.request": { "user_wants_to_verify": "%(name)s 想要验证", - "you_started": "你发送了一个验证请求" + "you_started": "你发送了验证请求" }, "m.location": { - "full": "%(senderName)s 分享了他们的位置", - "location": "分享了位置: ", - "self_location": "分享了他们的位置: " + "full": "%(senderName)s 分享了其位置", + "location": "分享位置:", + "self_location": "已分享其位置:" }, "m.poll": { "count_of_votes": { - "one": "%(count)s 票", - "other": "%(count)s 票" + "one": "%(count)s 个投票", + "other": "%(count)s 个投票" } }, "m.poll.end": { "sender_ended": "%(senderName)s 结束了投票" }, - "m.poll.start": "%(senderName)s 发起了投票:%(pollQuestion)s", + "m.poll.start": "%(senderName)s 发起投票: %(pollQuestion)s", "m.room.avatar": { - "changed": "%(senderDisplayName)s 更改了房间头像。", - "changed_img": "%(senderDisplayName)s 将房间的头像更改为 ", - "lightbox_title": "%(senderDisplayName)s 修改了 %(roomName)s 的头像", - "removed": "%(senderDisplayName)s 移除了房间头像。" + "changed": "%(senderDisplayName)s 已更改房间头像。", + "changed_img": "%(senderDisplayName)s 已将房间头像更改为 ", + "lightbox_title": "%(senderDisplayName)s 已更改房间 %(roomName)s 中的头像", + "removed": "%(senderDisplayName)s 已移除房间头像。" }, "m.room.canonical_alias": { "alt_added": { - "other": "%(senderName)s 为此房间添加备用地址 %(addresses)s。", - "one": "%(senderName)s 为此房间添加了备用地址 %(addresses)s。" + "one": "%(senderName)s 为此房间添加了备选地址 %(address)s。", + "other": "%(senderName)s 已为此房间添加了备用地址 %(addresses)s。" }, "alt_removed": { - "other": "%(senderName)s 为此房间移除了备用地址 %(addresses)s。", - "one": "%(senderName)s 为此房间移除了备用地址 %(addresses)s。" + "one": "%(senderName)s 已移除此房间的备选地址 %(address)s。", + "other": "%(senderName)s 已移除此房间的备选地址 %(addresses)s。" }, "changed": "%(senderName)s 更改了此房间的地址。", - "changed_alternative": "%(senderName)s 更改了此房间的备用地址。", - "changed_main_and_alternative": "%(senderName)s 更改了此房间的主要地址与备用地址。", - "removed": "%(senderName)s 移除了此房间的主要地址。", - "set": "%(senderName)s 将此房间的主要地址设为了 %(address)s。" + "changed_alternative": "%(senderName)s 更改了此房间的备选地址。", + "changed_main_and_alternative": "%(senderName)s 已更改此房间的主要地址与备用地址。", + "removed": "%(senderName)s 已移除此房间的主地址。", + "set": "%(senderName)s 已将此房间的主地址设置为 %(address)s。" }, "m.room.create": { - "continuation": "此房间是另一个对话的延续之处。", - "see_older_messages": "点击这里以查看更早的消息。" + "continuation": "此房间是另一个对话的延续。", + "see_older_messages": "点击此处查看更旧的消息。", + "unknown_predecessor": "找不到此房间的旧版本(房间 ID:%(roomId)s),并且我们未提供“via_servers”来查找它。", + "unknown_predecessor_guess_server": "无法找到此房间(房间 ID:%(roomId)s)的旧版本,并且尚未提供“via_servers”用于查找。根据房间 ID 猜测服务器或许可行。要尝试请点击此链接:" }, "m.room.guest_access": { - "can_join": "%(senderDisplayName)s 将此房间改为允许游客加入。", - "forbidden": "%(senderDisplayName)s 将此房间改为游客禁入。", - "unknown": "%(senderDisplayName)s 将此房间的游客加入规则改为 %(rule)s" + "can_join": "%(senderDisplayName)s 配置此房间为允许访客加入。", + "forbidden": "%(senderDisplayName)s 已阻止访客加入房间。", + "unknown": "%(senderDisplayName)s 已将访客的访问权限更改为 %(rule)s" }, "m.room.history_visibility": { - "invited": "%(senderName)s使未来的房间历史对所有房间成员从他们被邀请开始可见。", - "joined": "%(senderName)s使未来的房间历史对所有房间成员从他们加入开始可见。", - "shared": "%(senderName)s使未来的房间历史对所有房间成员可见。", - "unknown": "%(senderName)s使未来的房间历史对未知(%(visibility)s)可见。", - "world_readable": "%(senderName)s使未来的房间历史对任何人可见。" + "invited": "%(senderName)s 为此房间的所有成员调整了未来房间历史的可见性(自新成员被邀请进入时起)。", + "joined": "%(senderName)s 已将未来的房间历史记录对所有成员可见,自其加入房间时起。", + "shared": "%(senderName)s 使所有房间成员都能看到未来的房间历史记录。", + "unknown": "%(senderName)s 已将未来的房间历史记录对未知用户可见(%(visibility)s)。", + "world_readable": "%(senderName)s 调整了未来房间历史的可见性(对任何人可见)。" }, "m.room.join_rules": { - "invite": "%(senderDisplayName)s 将此房间改为仅限邀请。", - "knock": "%(senderDisplayName)s 将加入规则更改为 ”需要验证加入请求“。", - "public": "%(senderDisplayName)s 将此房间对知道此房间链接的人公开。", - "restricted": "%(senderDisplayName)s 更改了谁能加入这个房间。", - "restricted_settings": "%(senderDisplayName)s 更改了谁能加入这个房间。查看设置。", - "unknown": "%(senderDisplayName)s 将加入规则改为 %(rule)s" + "invite": "%(senderDisplayName)s 配置此房间为仅限邀请。", + "knock": "%(senderDisplayName)s 更改了加入规则以申请加入。", + "public": "%(senderDisplayName)s 向知道链接的人公开了房间。", + "restricted": "%(senderDisplayName)s 更改了允许加入此房间的人员。", + "restricted_settings": "%(senderDisplayName)s 已更改谁可以加入此房间。查看设置", + "unknown": "%(senderDisplayName)s 已将加入规则更改为 %(rule)s" }, "m.room.member": { - "accepted_3pid_invite": "%(targetName)s 已接受 %(displayName)s 的邀请", + "accepted_3pid_invite": "%(targetName)s 接受了 %(displayName)s 的邀请", "accepted_invite": "%(targetName)s 已接受邀请", - "ban": "%(senderName)s 已封禁 %(targetName)s", - "ban_reason": "%(senderName)s 已封禁 %(targetName)s: %(reason)s", - "change_avatar": "%(senderName)s 已更改他们的资料图片", - "change_name": "%(oldDisplayName)s将其显示名称改为%(displayName)s", - "change_name_avatar": "%(oldDisplayName)s更改了其显示名称和用户资料图片", - "invite": "%(senderName)s 已邀请 %(targetName)s", + "ban": "%(senderName)s 已封禁 1 个用户", + "ban_reason": "%(senderName)s 已封禁 1 个用户: %(reason)s", + "ban_reason_spoiler": "%(senderName)s 已封禁 ", + "ban_spoiler": "%(senderName)s 已封禁 : %(reason)s", + "change_avatar": "%(senderName)s 已更改其个人资料图像", + "change_name": "%(oldDisplayName)s 已将其显示名称更改为 %(displayName)s", + "change_name_avatar": "%(oldDisplayName)s 已更改其显示名称与个人资料图片", + "invite": "%(senderName)s 邀请了 %(targetName)s", "join": "%(targetName)s 已加入房间", - "kick": "%(senderName)s 移除了 %(targetName)s", - "kick_reason": "%(senderName)s 移除了 %(targetName)s:%(reason)s", - "left": "%(targetName)s 已离开房间", - "left_reason": "%(targetName)s 已离开房间:%(reason)s", - "no_change": "%(senderName)s 未发生更改", + "kick": "%(senderName)s 已移除 %(targetName)s", + "kick_reason": "%(senderName)s 已移除 %(targetName)s: %(reason)s", + "left": "%(targetName)s 离开了房间", + "left_reason": "%(targetName)s 离开了房间:%(reason)s", + "no_change": "%(senderName)s 未产生任何更改", "reject_invite": "%(targetName)s 已拒绝邀请", - "remove_avatar": "%(senderName)s 已移除他们的资料图片", - "remove_name": "%(senderName)s已移除他们的显示名称(%(oldDisplayName)s)", - "set_avatar": "%(senderName)s 已设置资料图片", - "set_name": "%(senderName)s已将他们的显示名称设置为%(displayName)s", - "unban": "%(senderName)s 已取消封禁 %(targetName)s", - "withdrew_invite": "%(senderName)s 已撤回向 %(targetName)s 的邀请", - "withdrew_invite_reason": "%(senderName)s 已撤回向 %(targetName)s 的邀请:%(reason)s" + "reject_invite_reason": "%(targetName)s 拒绝了邀请:%(reason)s", + "remove_avatar": "%(senderName)s 已移除其个人资料图像", + "remove_name": "%(senderName)s 已移除其显示名称(%(oldDisplayName)s)", + "set_avatar": "%(senderName)s 设置了个人资料图像", + "set_name": "%(senderName)s 已设置其显示名称为 %(displayName)s", + "unban": "%(senderName)s 解封了 %(targetName)s", + "withdrew_invite": "%(senderName)s 撤消了 %(targetName)s 的邀请", + "withdrew_invite_reason": "%(senderName)s 已撤消对 %(targetName)s 的邀请:%(reason)s" }, "m.room.name": { - "change": "%(senderDisplayName)s 将房间名称从 %(oldRoomName)s 改为 %(newRoomName)s。", - "remove": "%(senderDisplayName)s 移除了房间名称。", - "set": "%(senderDisplayName)s 将房间名称改为 %(roomName)s。" + "change": "%(senderDisplayName)s 已更改房间名称从 %(oldRoomName)s 到 %(newRoomName)s。", + "remove": "%(senderDisplayName)s 已移除房间名称。", + "set": "%(senderDisplayName)s 更改房间名称为 %(roomName)s。" }, "m.room.pinned_events": { - "changed": "%(senderName)s 更改了房间的置顶消息。", - "changed_link": "%(senderName)s 已更改此房间的固定消息。", - "pinned": "%(senderName)s将一条消息固定到此房间。查看所有固定消息。", - "pinned_link": "%(senderName)s 将一条消息固定到此房间。查看所有固定消息。", - "unpinned": "%(senderName)s从此房间中取消固定了一条消息。查看所有固定消息。", - "unpinned_link": "%(senderName)s 从此房间中取消固定了一条消息。查看所有固定消息。" + "changed": "%(senderName)s 已更改此房间已置顶的消息。", + "changed_link": "%(senderName)s 更改了房间的已置顶消息。", + "pinned": "%(senderName)s 将一个消息置顶到此房间。查看所有已置顶的消息。", + "pinned_link": "%(senderName)s 已在此房间置顶了一个消息。 查看所有已置顶的消息。", + "unpinned": "%(senderName)s 已在此房间取消置顶一个消息。查看所有已置顶的消息。", + "unpinned_link": "%(senderName)s 已在此房间取消置顶了一个消息。 查看所有已置顶的消息。" }, "m.room.power_levels": { - "changed": "%(senderName)s更改了%(powerLevelDiffText)s的权力级别。", - "user_from_to": "%(userId)s 从 %(fromPowerLevel)s 变为 %(toPowerLevel)s" + "changed": "%(senderName)s 已更改 %(powerLevelDiffText)s。", + "user_from_to": "%(userId)s 的权力值从 %(fromPowerLevel)s 到 %(toPowerLevel)s" }, "m.room.third_party_invite": { - "revoked": "%(senderName)s 撤销了对 %(targetDisplayName)s 加入房间的邀请。", - "sent": "%(senderName)s 向 %(targetDisplayName)s 发了加入房间的邀请。" + "revoked": "%(senderName)s 已撤消 %(targetDisplayName)s 加入房间的邀请。", + "sent": "%(senderName)s 已向 %(targetDisplayName)s 发送了加入房间的邀请。" }, - "m.room.tombstone": "%(senderDisplayName)s 升级了此房间。", + "m.room.tombstone": "%(senderDisplayName)s 已升级此房间。", "m.room.topic": { - "changed": "%(senderDisplayName)s 将话题修改为 “%(topic)s”。" + "changed": "%(senderDisplayName)s 已更改主题为“%(topic)s”。", + "removed": "%(senderDisplayName)s 已移除房间主题。" }, - "m.sticker": "%(senderDisplayName)s 发送了一张贴纸。", + "m.sticker": "%(senderDisplayName)s 发送了一个贴纸。", "m.video": { - "error_decrypting": "解密视频时出错" + "error_decrypting": "解密视频时出错", + "show_video": "显示视频" }, "m.widget": { - "added": "%(senderName)s 添加了 %(widgetName)s 挂件", - "jitsi_ended": "由 %(senderName)s 结束的视频会议", + "added": "%(senderName)s 添加了小部件:%(widgetName)s", + "jitsi_ended": "%(senderName)s 结束了视频会议", "jitsi_join_right_prompt": "从右侧的房间信息卡片加入会议", - "jitsi_join_top_prompt": "点击房间顶部加入会议", - "jitsi_started": "由 %(senderName)s 发起的视频会议", - "jitsi_updated": "由 %(senderName)s 更新的视频会议", - "modified": "%(senderName)s 修改了 %(widgetName)s 挂件", - "removed": "%(senderName)s 移除了 %(widgetName)s 挂件" + "jitsi_join_top_prompt": "在此房间加入会议的前排", + "jitsi_started": "%(senderName)s 已开始视频会议", + "jitsi_updated": "%(senderName)s 已更新视频会议", + "modified": "小部件 %(widgetName)s 已被 %(senderName)s 修改", + "removed": "%(senderName)s 已移除小部件:%(widgetName)s" }, "mab": { "copy_link_thread": "复制到消息列的链接", "view_in_room": "在房间内查看" }, "mjolnir": { - "changed_rule_glob": "%(senderName)s 更新了一个由于%(reason)s而禁止%(oldGlob)s跟%(newGlob)s匹配的规则", - "changed_rule_rooms": "%(senderName)s更改了一个由于%(reason)s而禁止房间%(oldGlob)s跟%(newGlob)s匹配的规则", - "changed_rule_servers": "%(senderName)s 更新了一个由于%(reason)s而禁止服务器%(oldGlob)s跟%(newGlob)s匹配的规则", - "changed_rule_users": "%(senderName)s 更改了一个由于%(reason)s而禁止用户%(oldGlob)s跟%(newGlob)s匹配的规则", - "created_rule": "%(senderName)s 创建了由于%(reason)s而禁止匹配%(glob)s的规则", - "created_rule_rooms": "%(senderName)s 创建了由于%(reason)s而禁止房间匹配%(glob)s的规则", - "created_rule_servers": "%(senderName)s 创建了由于%(reason)s而禁止服务器匹配%(glob)s的规则", - "created_rule_users": "%(senderName)s 创建了因为%(reason)s而禁止用户匹配%(glob)s的规则", - "message_hidden": "你已忽略此用户,所以其消息已被隐藏。仍然显示。", - "removed_rule": "%(senderName)s 移除了禁止匹配 %(glob)s 的规则", - "removed_rule_rooms": "%(senderName)s 删除了禁止房间匹配%(glob)s的规则", - "removed_rule_servers": "%(senderName)s 移除了禁止匹配 %(glob)s 的服务器的规则", - "removed_rule_users": "%(senderName)s 移除了禁止匹配 %(glob)s 的用户的规则", - "updated_invalid_rule": "%(senderName)s 更新了一个无效的禁止规则", - "updated_rule": "%(senderName)s 更新了由于%(reason)s而禁止匹配%(glob)s的规则", - "updated_rule_rooms": "%(senderName)s 更新了由于%(reason)s而禁止房间匹配%(glob)s的规则", - "updated_rule_servers": "%(senderName)s 更新了由于%(reason)s而禁止服务器匹配%(glob)s的规则", - "updated_rule_users": "%(senderName)s 更新了由于%(reason)s 而禁止用户匹配%(glob)s的规则" + "changed_rule_glob": "%(senderName)s 已更新一条与 %(oldGlob)s 匹配的封禁规则,曾经为 %(newGlob)s,原因:%(reason)s", + "changed_rule_rooms": "%(senderName)s 已更改一条与 %(newGlob)s 匹配的房间封禁规则,曾经为 %(oldGlob)s,原因:%(reason)s", + "changed_rule_servers": "%(senderName)s 已更改一条与 %(newGlob)s 匹配的服务器封禁规则,曾经为 %(oldGlob)s,原因:%(reason)s", + "changed_rule_users": "%(senderName)s 已更改一条与 %(newGlob)s 匹配的用户封禁规则,曾经为 %(oldGlob)s,原因:%(reason)s", + "created_rule": "%(senderName)s 为 %(reason)s 创建了一条与 %(glob)s 匹配的封禁规则", + "created_rule_rooms": "%(senderName)s 已创建一条与 %(glob)s 匹配的房间封禁规则,原因:%(reason)s", + "created_rule_servers": "%(senderName)s 已创建一条与 %(glob)s 匹配的服务器封禁规则,原因:%(reason)s", + "created_rule_users": "%(senderName)s 已创建一条与 %(glob)s 匹配的用户封禁规则,原因:%(reason)s", + "message_hidden": "你已忽略此用户,因此其消息已被隐藏。仍然显示。", + "removed_rule": "%(senderName)s 已移除一条与 %(glob)s 匹配的封禁规则", + "removed_rule_rooms": "%(senderName)s 已移除匹配 %(glob)s 的房间封禁规则。", + "removed_rule_servers": "%(senderName)s 已移除与 %(glob)s 匹配的服务器封禁规则", + "removed_rule_users": "%(senderName)s 已移除匹配 %(glob)s 的用户封禁规则。", + "updated_invalid_rule": "%(senderName)s 更新了无效的封禁规则", + "updated_rule": "%(senderName)s 已更新一条与 %(glob)s 匹配的封禁规则", + "updated_rule_rooms": "%(senderName)s 已更新一条与 %(glob)s 匹配的房间封禁规则,原因:%(reason)s", + "updated_rule_servers": "%(senderName)s 已更新与 %(glob)s 匹配的服务器封禁规则,原因:%(reason)s。", + "updated_rule_users": "%(senderName)s 已更新一条与 %(glob)s 匹配的用户封禁规则,原因:%(reason)s" }, - "no_permission_messages_before_invite": "你没有权限查看你被邀请之前的消息。", - "no_permission_messages_before_join": "你没有权限查看你加入前的消息。", - "pending_moderation": "待审核的消息", - "pending_moderation_reason": "消息待审核:%(reason)s", + "no_permission_messages_before_invite": "你无权查看你被邀请前的消息。", + "no_permission_messages_before_join": "你无权查看你加入前的消息。", + "pending_moderation": "消息等待审核", + "pending_moderation_reason": "消息等待审核:%(reason)s", "reactions": { "add_reaction_prompt": "添加反应", - "label": "%(reactors)s做出了%(content)s的反应" + "custom_reaction_fallback_label": "自定义反应", + "label": "%(reactors)s 使用 %(content)s 作出反应", + "tooltip_caption": "已使用 %(shortName)s 作出反应" }, "read_receipt_title": { - "one": "已被%(count)s人查看", - "other": "已被%(count)s人查看" + "one": "已被 %(count)s 个人查看", + "other": "已被 %(count)s 个人查看" }, "read_receipts_label": "已读回执", "redacted": { - "tooltip": "消息于 %(date)s 被删除" + "tooltip": "消息已于 %(date)s 删除" }, - "redaction": "消息被 %(name)s 删除", + "redaction": "消息已被 %(name)s 删除", "reply": { - "error_loading": "无法加载被回复的事件,它可能不存在,也可能是你没有权限查看它。", - "in_reply_to": "答复 ", - "in_reply_to_for_export": "答复此消息" + "error_loading": "无法加载已回复的事件,该事件可能不存在或你无权查看。", + "in_reply_to": "回复给 ", + "in_reply_to_for_export": "回复此消息" }, "scalar_starter_link": { - "dialog_description": "你将被带到一个第三方网站以便验证你的账户来使用 %(integrationsUrl)s 提供的集成。你希望继续吗?", + "dialog_description": "你将被带到第三方网站,以便验证你的账户是否可用于 %(integrationsUrl)s。是否继续?", "dialog_title": "添加集成" }, - "self_redaction": "消息已删除", + "self_redaction": "消息已被删除", + "send_state_encrypting": "正在解密消息…", "send_state_failed": "发送失败", + "send_state_sending": "发送消息…", "send_state_sent": "消息已发送", "summary": { "banned": { - "other": "被封禁 %(count)s 次", - "one": "被封禁" + "one": "已被封禁", + "other": "已被封禁 %(count)s 次" }, "banned_multiple": { - "other": "被封禁 %(count)s 次", - "one": "被封禁" + "one": "已被封禁", + "other": "已被封禁 %(count)s 次" + }, + "changed_avatar": { + "one": "%(oneUser)s 已更改其个人资料图像", + "other": "%(oneUser)s 已更改其个人资料图像 %(count)s 次" + }, + "changed_avatar_multiple": { + "one": "%(severalUsers)s 已更改其个人资料图像", + "other": "%(severalUsers)s 已更改其个人资料图像 %(count)s 次" }, "changed_name": { - "other": "%(oneUser)s 修改了自己的名称 %(count)s 次", - "one": "%(oneUser)s 修改了自己的名称" + "one": "%(oneUser)s 已更改其名称", + "other": "%(oneUser)s 已更改其名称 %(count)s 次" }, "changed_name_multiple": { - "other": "%(severalUsers)s 修改了他们的名称 %(count)s 次", - "one": "%(severalUsers)s 修改了他们的名称" + "one": "%(severalUsers)s 已更改其名称", + "other": "%(severalUsers)s 更改了其名称 %(count)s 次" }, + "format": "%(nameList)s %(transitionList)s", "hidden_event": { - "other": "%(oneUser)s发送了%(count)s条隐藏消息", - "one": "%(oneUser)s发送了一条隐藏消息" + "one": "%(oneUser)s 发送了一个隐藏消息", + "other": "%(oneUser)s 发送了 %(count)s 个隐藏消息" }, "hidden_event_multiple": { - "one": "%(severalUsers)s发送了一条隐藏消息", - "other": "%(severalUsers)s发送了%(count)s条隐藏消息" + "one": "%(severalUsers)s 发送了一个隐藏事件", + "other": "%(severalUsers)s 发送了 %(count)s 个隐藏消息" }, "invite_withdrawn": { - "other": "%(oneUser)s 撤回了他们的邀请共 %(count)s 次", - "one": "%(oneUser)s 撤回了他们的邀请" + "one": "%(oneUser)s 已撤消其邀请", + "other": "%(oneUser)s 已撤消其邀请 %(count)s 次" }, "invite_withdrawn_multiple": { - "other": "%(severalUsers)s 撤回了他们的邀请共 %(count)s 次", - "one": "%(severalUsers)s 撤回了他们的邀请" + "one": "已撤消 %(severalUsers)s 的邀请", + "other": "%(severalUsers)s 已撤消其邀请 %(count)s 次" }, "invited": { - "other": "被邀请 %(count)s 次", - "one": "被邀请" + "one": "被邀请", + "other": "已被邀请 %(count)s 次" }, "invited_multiple": { - "other": "被邀请 %(count)s 次", - "one": "被邀请" + "one": "已被邀请", + "other": "被邀请 %(count)s 次" }, "joined": { - "other": "%(oneUser)s 已加入 %(count)s 次", - "one": "%(oneUser)s 已加入" + "one": "%(oneUser)s 已加入", + "other": "%(oneUser)s 加入了 %(count)s 次" }, "joined_and_left": { - "other": "%(oneUser)s加入并离开了%(count)s次", - "one": "%(oneUser)s加入并离开了" + "one": "%(oneUser)s 已加入并离开", + "other": "%(oneUser)s 加入并离开了 %(count)s 次" }, "joined_and_left_multiple": { - "other": "%(severalUsers)s加入并离开了%(count)s次", - "one": "%(severalUsers)s加入并离开了" + "one": "%(severalUsers)s 加入并离开", + "other": "%(severalUsers)s 加入并离开 %(count)s 次" }, "joined_multiple": { - "other": "%(severalUsers)s 已加入 %(count)s 次", - "one": "%(severalUsers)s 已加入" + "one": "%(severalUsers)s 已加入", + "other": "%(severalUsers)s 加入了 %(count)s 次" }, "kicked": { "one": "被移除", - "other": "被移除%(count)s次" + "other": "已被移除 %(count)s 次" }, "kicked_multiple": { - "one": "被移除", - "other": "被移除了%(count)s次" + "one": "已被移除", + "other": "被移除 %(count)s 次" }, "left": { - "other": "%(oneUser)s 已离开 %(count)s 次", - "one": "%(oneUser)s 已离开" + "one": "%(oneUser)s 已离开", + "other": "%(oneUser)s 离开 %(count)s 次" }, "left_multiple": { - "other": "%(severalUsers)s 已离开 %(count)s 次", - "one": "%(severalUsers)s 已离开" + "one": "%(severalUsers)s 已离开", + "other": "%(severalUsers)s 离开 %(count)s 次" }, "no_change": { - "other": "%(oneUser)s 未做更改 %(count)s 次", - "one": "%(oneUser)s 未做更改" + "one": "%(oneUser)s 未产生任何更改", + "other": "%(oneUser)s未产生任何更改 %(count)s 次" }, "no_change_multiple": { - "other": "%(severalUsers)s 未做更改 %(count)s 次", - "one": "%(severalUsers)s 未做更改" + "one": "%(severalUsers)s 未产生任何更改", + "other": "%(severalUsers)s 未产生任何更改 %(count)s 次" }, "pinned_events": { - "one": "%(oneUser)s更改了房间的固定消息", - "other": "%(oneUser)s更改了房间的固定消息%(count)s次" + "one": "%(oneUser)s 更改了此房间的已置顶消息", + "other": "%(oneUser)s 已更改房间内的 已置顶消息 %(count)s 次" }, "pinned_events_multiple": { - "one": "%(severalUsers)s更改了房间的固定消息", - "other": "%(severalUsers)s更改了房间的固定消息%(count)s次" + "one": "%(severalUsers)s 已更改房间的 已置顶消息", + "other": "%(severalUsers) 已更改该房间的已置顶消息 %(count)s 次。" }, "redacted": { - "one": "%(oneUser)s移除了一条消息", - "other": "%(oneUser)s移除了%(count)s条消息" + "one": "%(oneUser)s 已移除一个消息", + "other": "%(oneUser)s 已移除 %(count)s 个消息" }, "redacted_multiple": { - "one": "%(severalUsers)s移除了1条消息", - "other": "%(severalUsers)s移除了%(count)s条消息" + "one": "%(severalUsers)s 已移除一个消息", + "other": "%(severalUsers)s 已移除 %(count)s 个消息" }, "rejected_invite": { - "other": "%(oneUser)s 拒绝了他们的邀请共 %(count)s 次", - "one": "%(oneUser)s 拒绝了他们的邀请" + "one": "%(oneUser)s 已拒绝其邀请", + "other": "%(oneUser)s 已拒绝其邀请 %(count)s 次" }, "rejected_invite_multiple": { - "one": "%(severalUsers)s 拒绝了他们的邀请", - "other": "%(severalUsers)s 拒绝了他们的邀请共 %(count)s 次" + "one": "%(severalUsers)s 已拒绝其邀请", + "other": "%(severalUsers)s 已拒绝其邀请 %(count)s 次" }, "rejoined": { - "other": "%(oneUser)s离开并重新加入了%(count)s次", - "one": "%(oneUser)s离开并重新加入了" + "one": "%(oneUser)s 离开并重新加入", + "other": "%(oneUser)s 离开并重新加入了 %(count)s 次" }, "rejoined_multiple": { - "other": "%(severalUsers)s离开并重新加入了%(count)s次", - "one": "%(severalUsers)s离开并重新加入了" + "one": "%(severalUsers)s 离开并重新加入", + "other": "%(severalUsers)s 离开并重新加入了 %(count)s 次" }, "unbanned": { - "other": "被解封 %(count)s 次", - "one": "被解封" + "one": "已被解封", + "other": "被解封 %(count)s 次" }, "unbanned_multiple": { "other": "被解封 %(count)s 次", @@ -2874,274 +3737,327 @@ "thread_info_basic": "来自消息列", "typing_indicator": { "more_users": { - "other": "%(names)s 与其他 %(count)s 位正在输入…", - "one": "%(names)s 与另一位正在输入…" + "one": "%(name)s 与剩余 1 个用户正在输入...", + "other": "%(names)s 与其他 %(count)s 个人正在输入…" }, "one_user": "%(displayName)s 正在输入…", - "two_users": "%(names)s和%(lastPerson)s正在输入……" - } + "two_users": "%(names)s 与其他 %(lastPerson)s 个人正在输入…" + }, + "undecryptable_tooltip": "此消息无法解密" }, "truncated_list_n_more": { - "other": "和 %(count)s 个其他…" + "other": "以及剩余 %(count)s 个…" }, + "unsupported_browser": { + "description": "如果继续,某些功能可能会停止运行,并且你将来可能会丢失数据。请更新浏览器以继续使用 %(brand)s。", + "title": "%(brand)s 不支持此浏览器" + }, + "unsupported_server_description": "此服务器正在使用旧版本的 Matrix。请升级到 Matrix %(version)s 以正常使用 %(brand)s。", + "unsupported_server_title": "服务器不支持", "update": { "changelog": "更改日志", "check_action": "检查更新", - "error_encountered": "遇到错误 (%(errorDetail)s)。", - "error_unable_load_commit": "无法加载提交详情:%(msg)s", - "new_version_available": "新版本可用。现在更新。", - "no_update": "没有可用更新。", - "release_notes_toast_title": "更新内容", - "see_changes_button": "有何新变动?", - "toast_description": "%(brand)s 有新版本可用", + "checking": "正在检查更新…", + "downloading": "正在下载更新…", + "error_encountered": "遇到错误(%(errorDetail)s)。", + "error_unable_load_commit": "无法载入提交的详细信息: %(msg)s", + "new_version_available": "有新版本可用。立即更新", + "no_update": "无可用更新。", + "release_notes_toast_title": "新特性", + "see_changes_button": "新增内容?", + "toast_description": "%(brand)s 有新版本可供更新", "toast_title": "更新 %(brand)s", - "unavailable": "无法获得" + "unavailable": "不可用" }, - "upload_failed_generic": "文件《%(fileName)s》上传失败。", - "upload_failed_size": "文件“%(fileName)s”超过了此家服务器的上传大小限制", + "update_room_access_modal": { + "description": "要创建共享链接,请将此房间设为公开,或启用用户申请加入的选项。这样,访客无需邀请即可加入。", + "dont_change_description": "如果你不想更改此房间的访问权限,你可以为通话链接创建一个新的房间。", + "no_change": "我不想更改访问权力。", + "revert_access_description": "(可在“房间设置”中将其恢复为先前的值:安全与隐私/访问)", + "title": "允许访客加入此房间" + }, + "upload_failed_generic": "文件“%(fileName)s”上传失败。", + "upload_failed_size": "文件“%(fileName)s”超出了此主服务器的上传大小限制", "upload_failed_title": "上传失败", "upload_file": { "cancel_all_button": "全部取消", - "error_file_too_large": "此文件过大而不能上传。文件大小限制是 %(limit)s 但此文件为 %(sizeOfThisFile)s。", - "error_files_too_large": "这些文件过大而不能上传。文件大小限制为 %(limit)s。", - "error_some_files_too_large": "一些文件过大而不能上传。文件大小限制为 %(limit)s。", - "error_title": "上传错误", + "error_file_too_large": "此文件太大,无法上传。文件大小限制为 %(limit)s,但此文件为 %(sizeOfThisFile)s。", + "error_files_too_large": "这些文件太大,无法上传。文件大小限制为 %(limit)s。", + "error_some_files_too_large": "某些文件过大,无法上传。文件大小限制为 %(limit)s。", + "error_title": "上传出错", + "not_image": "你选择的文件不是有效的图像文件。", "title": "上传文件", - "title_progress": "上传文件(%(total)s 中之 %(current)s)", + "title_progress": "上传文件(%(total)s 个中的第 %(current)s 个)", "upload_all_button": "全部上传", "upload_n_others_button": { - "other": "上传 %(count)s 个别的文件", - "one": "上传 %(count)s 个别的文件" + "one": "上传剩余 %(count)s 个文件", + "other": "上传剩余 %(count)s 个文件" } }, "user_info": { "admin_tools_section": "管理员工具", "ban_button_room": "从房间封禁", "ban_button_space": "从空间封禁", - "ban_room_confirm_title": "禁止进入 %(roomName)s", - "ban_space_everything": "禁止这些人做任何我有权决定的事", + "ban_room_confirm_title": "在 %(roomName)s 中封禁", + "ban_space_everything": "禁止他们访问我所能访问的一切", "ban_space_specific": "禁止这些人做某些我有权决定的事", "deactivate_confirm_action": "停用用户", - "deactivate_confirm_description": "停用此用户将会使其登出并阻止其再次登入。而且此用户也会离开其所在的所有房间。此操作不可逆。你确定要停用此用户吗?", - "deactivate_confirm_title": "停用用户吗?", + "deactivate_confirm_description": "停用此用户将移除其设备,并且无法重新登录。同时将离开其所在的所有房间。此操作无法撤消。你确定要停用此用户?", + "deactivate_confirm_title": "停用用户?", "demote_button": "降权", - "demote_self_confirm_description_space": "当你将自己降级后,你将无法撤销此更改。如果你是此空间的最后一名拥有权限的用户,则无法重新获得权限。", - "demote_self_confirm_room": "如果你是房间中最后一位拥有权限的用户,在你降低自己的权限等级后将无法撤销此修改,你将无法重新获得权限。", - "demote_self_confirm_title": "是否降低你自己的权限?", - "disinvite_button_room": "从房间取消邀请", - "disinvite_button_room_name": "取消邀请加入 %(roomName)s", - "disinvite_button_space": "从空间取消邀请", - "error_ban_user": "封禁失败", - "error_deactivate": "停用用户失败", - "error_kicking_user": "移除用户失败", - "error_mute_user": "禁言用户失败", - "error_revoke_3pid_invite_description": "无法撤销邀请。此服务器可能出现了临时错误,或者你没有足够的权限来撤销邀请。", - "error_revoke_3pid_invite_title": "撤销邀请失败", - "invited_by": "被 %(sender)s 邀请", - "jump_to_rr_button": "跳到阅读回执", + "demote_self_confirm_description_space": "由于你正在降级自身,因此无法撤消此更改。如果你是房间中最后一个拥有特权的用户,则无法重新获得特权。", + "demote_self_confirm_room": "由于你正在降级自身,因此无法撤消此更改。如果你是房间中最后一个拥有特权的用户,则无法重新获得特权。", + "demote_self_confirm_title": "降级自身?", + "disinvite_button_room": "撤消邀请", + "disinvite_button_room_name": "从 %(roomName)s 取消邀请", + "disinvite_button_space": "取消邀请", + "error_ban_user": "封禁用户失败", + "error_deactivate": "用户停用失败", + "error_kicking_user": "用户移除失败", + "error_mute_user": "静默用户失败", + "error_revoke_3pid_invite_description": "无法撤消邀请。服务器可能出现临时问题,或者你没有足够的权限撤消邀请。", + "error_revoke_3pid_invite_title": "邀请撤消失败", + "ignore_button": "忽略", + "ignore_confirm_description": "来自此用户的所有消息与邀请都将被隐藏。你确定要忽略?", + "ignore_confirm_title": "忽略 %(user)s", + "invited_by": "由 %(sender)s 邀请", + "jump_to_rr_button": "跳转到已读回执", "kick_button_room": "从房间移除", - "kick_button_room_name": "从%(roomName)s移除", + "kick_button_room_name": "从 %(roomName)s 移除", "kick_button_space": "从空间移除", - "kick_space_warning": "他们仍然可以访问任何你不是管理员的地方。", - "promote_warning": "你将无法撤回此修改,因为此用户的权力级别将与你相同。", + "kick_button_space_everything": "从我所能及的一切中将它们移除", + "kick_space_specific": "将它们从我能够处理的特定事物中移除", + "kick_space_warning": "他们仍然可以访问任何你不是管理员的空间。", + "promote_warning": "由于你正在将该用户提升为与你相同的权力值,因此你将无法撤消此更改。", "redact": { "confirm_button": { - "other": "删除 %(count)s 条消息", - "one": "删除 1 条消息" + "one": "移除 1 个消息", + "other": "移除 %(count)s 个消息" }, - "confirm_description_2": "对于大量消息,可能会消耗一段时间。在此期间请不要刷新你的客户端。", - "confirm_keep_state_explainer": "若你也想移除关于此用户的系统消息(例如,成员更改、用户资料更改……),则取消勾选", + "confirm_description_1": { + "one": "即将移除 %(user)s 发送的 %(count)s 个消息。这将永久删除对话中所有参与者的消息。是否继续?", + "other": "即将移除 %(user)s 发送的 %(count)s 个消息。这将永久删除对话中所有参与者的消息。是否继续?" + }, + "confirm_description_2": "如果消息量较大,这可能需要一些时间。在此期间请勿刷新客户端。", + "confirm_keep_state_explainer": "如果你还想移除此用户的系统消息(例如,成员关系变更、个人资料变更等),请取消选中。", "confirm_keep_state_label": "保留系统消息", - "confirm_title": "删除 %(user)s 最近发送的消息", - "no_recent_messages_description": "请尝试在时间线中向上滚动以查看是否有更早的。", - "no_recent_messages_title": "没有找到 %(user)s 最近发送的消息" + "confirm_title": "移除 %(user)s 最近的信息", + "no_recent_messages_description": "尝试向上滚动时间线,查看是否有更早的消息。", + "no_recent_messages_title": "未找到 %(user)s 的最近信息" }, - "redact_button": "移除最近消息", - "revoke_invite": "撤销邀请", - "room_encrypted": "此房间内的消息端到端加密。", - "room_encrypted_detail": "你的消息是安全的,只有你和接收者有解开它们的唯一密钥。", - "room_unencrypted": "此房间内的消息未端到端加密。", - "room_unencrypted_detail": "在加密房间中,你的消息是安全的,只有你和接收者有解开它们的唯一密钥。", - "share_button": "分享链接给其他用户", - "unban_button_room": "从房间取消解封", - "unban_button_space": "从空间取消封锁", - "unban_room_confirm_title": "解除 %(roomName)s 禁令", + "redact_button": "移除消息", + "revoke_invite": "撤消申请", + "room_encrypted": "此处的消息是端到端加密的。", + "room_encrypted_detail": "你的消息是安全的,只有你与对方拥有解锁它们的唯一密钥。", + "room_unencrypted": "在此房间中的消息非端到端加密。", + "room_unencrypted_detail": "在加密房间中你的消息是安全的,只有你与收件人拥有解密它们的唯一密钥。", + "send_message": "发送消息", + "share_button": "分享个人资料", + "unban_button_room": "从房间解封", + "unban_button_space": "从空间解封", + "unban_room_confirm_title": "从 %(roomName)s 解封", "unban_space_everything": "解除我权限范围内对这些人的所有禁令", "unban_space_specific": "解除我权限范围内对这些人的某些禁令", - "unban_space_warning": "他们将无法访问你不是管理员的一切。", + "unban_space_warning": "如果你不是管理员,他们将无法访问。", + "unignore_button": "取消忽略", + "verification_unavailable": "用户验证不可用", "verify_button": "验证用户", - "verify_explainer": "为了更加安全,在你两个设备上检查一次性代码来验证此用户。" + "verify_explainer": "为了提高安全性,请通过检查两台设备上的一次性代码来验证此用户。" }, "user_menu": { + "link_new_device": "关联新设备", "settings": "所有设置", - "switch_theme_dark": "切换到深色模式", - "switch_theme_light": "切换到浅色模式" + "switch_theme_dark": "切换为深色模式", + "switch_theme_light": "切换为浅色模式" }, "voip": { - "already_in_call": "正在通话中", - "already_in_call_person": "你正在与其通话。", + "already_in_call": "已在通话中", + "already_in_call_person": "你已正在与此人通话。", "answered_elsewhere": "已在别处接听", - "answered_elsewhere_description": "已在另一台设备上接听了此通话。", - "call_failed": "呼叫失败", + "answered_elsewhere_description": "通话已在其它设备上接听。", + "call_failed": "通话失败", "call_failed_description": "无法建立通话", - "call_failed_media": "通话失败,因为无法使用摄像头或麦克风。请检查:", - "call_failed_media_applications": "没有其他应用程序正在使用摄像头", - "call_failed_media_connected": "已插入并正确设置麦克风和摄像头", - "call_failed_media_permissions": "授权使用摄像头", - "call_failed_microphone": "呼叫失败,因为无法使用任何麦克风。 检查是否已插入并正确设置麦克风。", - "call_held": "%(peerName)s 挂起了通话", - "call_held_resume": "你挂起了通话 恢复", - "call_held_switch": "你挂起了通话 切换", + "call_failed_media": "通话失败,因为无法访问摄像头或麦克风。请检查:", + "call_failed_media_applications": "无其它应用程序使用摄像头", + "call_failed_media_connected": "麦克风与摄像头已插入并正确设置", + "call_failed_media_permissions": "已授予摄像头使用权", + "call_failed_microphone": "通话失败,因为无法访问麦克风。请检查麦克风是否已插入并正确设置。", + "call_held": "%(peerName)s 已挂起通话", + "call_held_resume": "你正在通话 恢复", + "call_held_switch": "你正在通话 切换", "call_toast_unknown_room": "未知房间", - "camera_disabled": "你的摄像头已关闭", - "camera_enabled": "你的摄像头仍然处于启用状态", - "cannot_call_yourself_description": "你不能打给自己。", - "connecting": "连接中", - "connection_lost": "已丢失与服务器的连接", - "connection_lost_description": "你不能在未连接到服务器时进行呼叫。", - "consulting": "与 %(transferTarget)s 进行协商。转让至 %(transferee)s", + "camera_disabled": "摄像头已关闭", + "camera_enabled": "摄像头仍处于开启状态", + "cannot_call_yourself_description": "你无法与自己通话。", + "close_lobby": "关闭大厅", + "connecting": "正在连接", + "connection_lost": "与服务器的连接已丢失", + "connection_lost_description": "在未连接到服务器的情况下,你无法拨打电话。", + "consulting": "正在与 %(transferTarget)s 协商。转接到 %(transferee)s", + "decline_call": "拒绝", "default_device": "默认设备", "dial": "拨号", "dialpad": "拨号盘", - "disable_camera": "关闭相机", + "disable_camera": "关闭摄像头", "disable_microphone": "静音麦克风", - "disabled_no_perms_start_video_call": "你没有权限开始视频通话", - "disabled_no_perms_start_voice_call": "你没有权限开始语音通话", - "disabled_ongoing_call": "正在进行的通话", - "enable_camera": "启动相机", + "disabled_no_perms_start_video_call": "你无权发起视频通话", + "disabled_no_perms_start_voice_call": "你无权发起语音通话", + "disabled_ongoing_call": "通话中", + "element_call": "Element Call", + "enable_camera": "打开摄像头", "enable_microphone": "取消静音麦克风", - "expand": "返回通话", + "expand": "返回到通话", + "get_call_link": "分享通话链接", "hangup": "挂断", - "hide_sidebar_button": "隐藏侧边栏", + "hide_sidebar_button": "隐藏边栏", "input_devices": "输入设备", - "maximise": "填满屏幕", - "misconfigured_server": "服务器配置错误导致通话失败", - "misconfigured_server_description": "请联系你的家服务器(%(homeserverDomain)s)的管理员配置 TURN 服务器,以确保通话过程稳定。", + "jitsi_call": "Jitsi 会议", + "legacy_call": "旧版通话", + "maximise": "填充屏幕", + "maximise_call": "最大化", + "metaspace_video_rooms": { + "conference_room_section": "会议" + }, + "minimise_call": "最小化", + "misconfigured_server": "由于服务器配置错误,通话失败", + "misconfigured_server_description": "请要求主服务器 (%(homeserverDomain)s) 的管理员配置 TURN 服务器,以确保通话可靠运行。", + "misconfigured_server_fallback": "或者,你可以尝试使用位于 的公共服务器,但这不完全可靠,并且会与该服务器共享 IP 地址。你也可以在“设置”中管理此设置。", + "misconfigured_server_fallback_accept": "尝试使用 %(server)s", "more_button": "更多", "msisdn_lookup_failed": "无法查询电话号码", - "msisdn_lookup_failed_description": "查询电话号码时发生错误", - "msisdn_transfer_failed": "无法转移通话", + "msisdn_lookup_failed_description": "查询电话号码时出错", + "msisdn_transfer_failed": "无法转接通话", "n_people_joined": { - "one": "%(count)s个人已加入", - "other": "%(count)s个人已加入" + "one": "%(count)s 个人已加入", + "other": "%(count)s 个人已加入" }, - "no_audio_input_description": "我们没能在你的设备上找到麦克风。请检查设置并重试。", + "no_audio_input_description": "我们未在此设备上找到麦克风。请检查设置并重试。", "no_audio_input_title": "未找到麦克风", - "no_media_perms_description": "你可能需要手动授权 %(brand)s 使用你的麦克风或摄像头", - "no_media_perms_title": "没有媒体存取权限", + "no_media_perms_description": "你可能需要手动允许 %(brand)s 访问你的麦克风或摄像头。", + "no_media_perms_title": "无媒体权限", "no_permission_conference": "需要权限", - "no_permission_conference_description": "你没有在此房间发起通话会议的权限", + "no_permission_conference_description": "你无权在此房间启动会议通话", "on_hold": "保留 %(name)s", "output_devices": "输出设备", "screenshare_monitor": "分享整个屏幕", "screenshare_title": "分享内容", "screenshare_window": "应用程序窗口", - "show_sidebar_button": "显示侧边栏", - "silence": "通话静音", + "show_sidebar_button": "显示边栏", + "silence": "静音通话", "silenced": "通知已静音", - "start_screenshare": "开始分享你的屏幕", - "stop_screenshare": "停止分享你的屏幕", - "too_many_calls": "太多通话", + "skip_lobby_toggle_option": "立即加入", + "start_screenshare": "开始分享屏幕", + "stop_screenshare": "停止分享屏幕", + "too_many_calls": "呼叫频繁", "too_many_calls_description": "你已达到同时通话的最大数量。", - "transfer_consult_first_label": "先询问", - "transfer_failed": "转移失败", - "transfer_failed_description": "通话转移失败", - "unable_to_access_audio_input_description": "我们无法访问你的麦克风。 请检查浏览器设置并重试。", + "transfer_consult_first_label": "优先咨询", + "transfer_failed": "传输失败", + "transfer_failed_description": "通话转接失败", + "unable_to_access_audio_input_description": "我们无法访问麦克风。请检查浏览器设置并重试。", "unable_to_access_audio_input_title": "无法访问你的麦克风", - "unable_to_access_media": "无法使用摄像头/麦克风", - "unable_to_access_microphone": "无法使用麦克风", - "unknown_caller": "未知来电人", - "unknown_person": "陌生人", + "unable_to_access_media": "无法访问摄像头或麦克风", + "unable_to_access_microphone": "无法访问麦克风", + "unknown_caller": "未知呼叫方", + "unknown_person": "未知人员", "unsilence": "开启声音", - "unsupported": "不支持通话", - "unsupported_browser": "你无法在此浏览器中进行呼叫。", - "user_busy": "用户正忙", - "user_busy_description": "你所呼叫的用户正忙。", - "user_is_presenting": "%(sharerName)s 正在展示", + "unsupported": "不支持的通话", + "unsupported_browser": "你无法在此浏览器中执行通话。", + "user_busy": "用户忙", + "user_busy_description": "你呼叫的用户正忙。", + "user_is_presenting": "%(sharerName)s 正在分享", "video_call": "视频通话", - "video_call_started": "视频通话已开始", + "video_call_incoming": "视频通话来电", + "video_call_started": "已开始视频通话", + "video_call_using": "视频通话时使用:", "voice_call": "语音通话", - "you_are_presenting": "你正在展示" + "voice_call_incoming": "语音通话来电", + "voice_call_using": "语音通话时使用:", + "you_are_presenting": "你正在演示" + }, + "web_default_device_name": "%(appName)s:%(browserName)s 运行于 %(osName)s", + "welcome": { + "tagline_element": "更快速、更简洁。", + "title_element": "融入 Element", + "title_generic": "欢迎使用 %(brand)s" }, - "web_default_device_name": "%(appName)s:%(browserName)s在%(osName)s", - "welcome_to_element": "欢迎来到 Element", "widget": { - "added_by": "挂件添加者", + "added_by": "小部件添加者:", "capabilities_dialog": { - "content_starting_text": "此挂件想要:", + "content_starting_text": "此小部件期望:", "decline_all_permission": "全部拒绝", - "remember_Selection": "记住我对此挂件的选择", - "title": "批准挂件权限" + "remember_Selection": "记住我对此小部件的选择", + "title": "批准小部件权限" }, "capability": { - "always_on_screen_generic": "运行时始终保留在你的屏幕上", - "always_on_screen_viewing_another_room": "运行时始终保留在你的屏幕上,即使你在浏览其它房间", - "any_room": "以上,但也包括你加入或被邀请的任何房间中", - "byline_empty_state_key": "附带一个空的状态键(state key)", + "always_on_screen_generic": "运行期间持续显示于屏幕", + "always_on_screen_viewing_another_room": "正在运行并且正在查看另一个房间时持续保留在屏幕上", + "any_room": "同上,但在任何你加入或被邀请到的房间中也是如此", + "byline_empty_state_key": "使用空状态值", "byline_state_key": "附带有状态键(state key)%(stateKey)s", - "capability": "%(capability)s 容量", + "capability": "%(capability)s 能力", "change_avatar_active_room": "更改活跃房间的头像", - "change_avatar_this_room": "更改当前房间的头像", + "change_avatar_this_room": "更改此房间的头像", "change_name_active_room": "更改活跃房间的名称", - "change_name_this_room": "更改当前房间的名称", - "change_topic_active_room": "更改当前活跃房间的话题", - "change_topic_this_room": "更改当前房间的话题", - "receive_membership_active_room": "查看人们何时加入、离开或被邀请到你所活跃的房间", - "receive_membership_this_room": "查看人们加入、离开或被邀请到此房间的时间", - "remove_ban_invite_leave_active_room": "移除、封禁或邀请他人加入你的活跃房间,方可离开", - "remove_ban_invite_leave_this_room": "移除、封禁或邀请他人加入此房间,方可离开", - "see_avatar_change_active_room": "查看你的活跃房间的头像何时修改", - "see_avatar_change_this_room": "查看此房间的头像何时被修改", - "see_event_type_sent_active_room": "查看你的活跃房间中发送的 %(eventType)s 事件", - "see_event_type_sent_this_room": "查看此房间中发送的 %(eventType)s 事件", - "see_images_sent_active_room": "查看发布到你所活跃的房间的图片", - "see_images_sent_this_room": "查看发布到此房间的图片", - "see_messages_sent_active_room": "查看发布到你所活跃的房间的消息", + "change_name_this_room": "更改在此房间的名称", + "change_topic_active_room": "更改活跃房间的主题", + "change_topic_this_room": "更改此房间的主题", + "download_file": "从媒体仓库下载文件", + "receive_membership_active_room": "查看活跃房间何时有人加入、离开或被邀请", + "receive_membership_this_room": "查看此房间何时有人加入、离开或被邀请", + "remove_ban_invite_leave_active_room": "移除、禁止或邀请他人进入活跃房间并使你离开", + "remove_ban_invite_leave_this_room": "移除、禁止或邀请他人进入此房间并使你离开", + "see_avatar_change_active_room": "查看活跃房间中的头像何时被更改", + "see_avatar_change_this_room": "查看此房间的头像何时被更改", + "see_event_type_sent_active_room": "查看发布到活跃房间的 %(eventType)s 事件", + "see_event_type_sent_this_room": "查看发布到此房间的 %(eventType)s 事件", + "see_images_sent_active_room": "查看发送到活跃房间的图像", + "see_images_sent_this_room": "查看发布到此房间的图像", + "see_messages_sent_active_room": "查看发送到活跃房间的消息", "see_messages_sent_this_room": "查看发布到此房间的消息", - "see_msgtype_sent_active_room": "查看发布到你所活跃的房间的 %(msgtype)s 消息", + "see_msgtype_sent_active_room": "查看发布活跃房间的 %(msgtype)s 消息", "see_msgtype_sent_this_room": "查看发布到此房间的 %(msgtype)s 消息", - "see_name_change_active_room": "查看你的活跃房间的名称何时被修改", - "see_name_change_this_room": "查看此房间的名称何时被修改", - "see_sent_emotes_active_room": "查看发布到你所活跃的房间的表情", + "see_name_change_active_room": "查看活跃房间中的名称何时被更改", + "see_name_change_this_room": "查看此房间的名称何时被更改", + "see_sent_emotes_active_room": "查看发送到活跃房间的表情", "see_sent_emotes_this_room": "查看发布到此房间的表情", - "see_sent_files_active_room": "查看发布到你所活跃的房间的一般性文件", - "see_sent_files_this_room": "查看发布到此房间的一般性文件", - "see_sticker_posted_active_room": "查看何时有人发送贴纸到你所活跃的房间", - "see_sticker_posted_this_room": "查看此房间中何时有人发送贴纸", - "see_text_messages_sent_active_room": "查看发布到你所活跃的房间的文本消息", - "see_text_messages_sent_this_room": "查看发布到此房间的文本消息", - "see_topic_change_active_room": "查看你的活跃房间的话题何时被修改", - "see_topic_change_this_room": "查看此房间的话题何时被修改", - "see_videos_sent_active_room": "查看发布到你所活跃的房间的视频", + "see_sent_files_active_room": "查看发布到活跃房间的普通文件", + "see_sent_files_this_room": "查看发送到此房间的普通文件", + "see_sticker_posted_active_room": "查看任何人发布到活跃房间的贴纸", + "see_sticker_posted_this_room": "查看此房间的贴纸何时被发送", + "see_text_messages_sent_active_room": "查看发布到活跃房间的文本消息", + "see_text_messages_sent_this_room": "查看发送到活跃房间的文本消息", + "see_topic_change_active_room": "查看活跃房间中的主题何时被更改", + "see_topic_change_this_room": "查看此房间的主题何时被更改", + "see_videos_sent_active_room": "查看发送到活跃房间的视频", "see_videos_sent_this_room": "查看发布到此房间的视频", - "send_emotes_active_room": "在你所活跃的房间以你的身份发送表情", - "send_emotes_this_room": "在此房间以你的身份发送表情", - "send_event_type_active_room": "以你的身份在你的活跃房间发送%(eventType)s事件", + "send_emotes_active_room": "以你的身份在活跃房间发送表情", + "send_emotes_this_room": "以你的身份在此房间发送表情", + "send_event_type_active_room": "以你的身份在活跃房间中发送%(eventType)s事件", "send_event_type_this_room": "以你的身份在此房间发送 %(eventType)s 事件", - "send_files_active_room": "在你所活跃的房间以你的身份发送一般性文件", - "send_files_this_room": "查看发布到此房间的一般性文件", - "send_images_active_room": "在你所活跃的房间以你的身份发送图片", - "send_images_this_room": "在此房间以你的身份发送图片", - "send_messages_active_room": "在你所活跃的房间以你的身份发送消息", - "send_messages_this_room": "在此房间以你的身份发送消息", - "send_msgtype_active_room": "在你所活跃的房间以你的身份发送 %(msgtype)s 消息", - "send_msgtype_this_room": "在此房间以你的身份发送 %(msgtype)s 消息", - "send_stickers_active_room": "发送贴纸到你的活跃房间", - "send_stickers_active_room_as_you": "发送贴纸到你所活跃的房间", - "send_stickers_this_room": "发送贴纸到此房间", - "send_stickers_this_room_as_you": "以你的身份发送贴纸到此房间", - "send_text_messages_active_room": "在你所活跃的房间以你的身份发送文本消息", - "send_text_messages_this_room": "在此房间以你的身份发送文本消息", - "send_videos_active_room": "查看发布到你所活跃的房间的视频", - "send_videos_this_room": "查看发布到此房间的视频", + "send_files_active_room": "以你的身份在活跃房间发送普通文件", + "send_files_this_room": "以你的身份在此房间发送普通文件", + "send_images_active_room": "以你的身份在活跃房间发送图像", + "send_images_this_room": "以你的身份在此房间发送图片", + "send_messages_active_room": "以你的身份在活跃房间发送消息", + "send_messages_this_room": "以你的身份在此房间中发送信息", + "send_msgtype_active_room": "以你的身份在活跃房间中发送%(msgtype)s消息", + "send_msgtype_this_room": "以你的身份在此房间发送 %(msgtype)s 消息", + "send_stickers_active_room": "向你的活跃房间发送贴纸", + "send_stickers_active_room_as_you": "以你的身份在活跃房间发送贴纸", + "send_stickers_this_room": "在此房间发送贴纸", + "send_stickers_this_room_as_you": "以你的身份在此房间中发送贴纸", + "send_text_messages_active_room": "以你的身份在活跃房间发送文本消息", + "send_text_messages_this_room": "以你的身份在此房间发送文本消息", + "send_videos_active_room": "以你的身份在活跃房间发送视频", + "send_videos_this_room": "以你的身份在此房间发送视频", "specific_room": "以上,但也包括 ", - "switch_room": "更改当前正在查看哪个房间", - "switch_room_message_user": "更改当前正在查看哪个房间、消息或用户" + "switch_room": "更改你正在查看的房间", + "switch_room_message_user": "更改正在查看的房间、消息或用户" }, - "close_to_view_right_panel": "关闭此小部件以在此面板中查看", + "close_to_view_right_panel": "关闭此小部件以在此面板查看", "context_menu": { - "delete": "删除挂件", - "delete_warning": "删除挂件时将为房间中的所有成员删除。你确定要删除此挂件吗?", + "delete": "删除小部件", + "delete_warning": "删除小部件的同时会为该房间中的所有用户删除。你确定要删除此小部件?", "move_left": "向左移动", "move_right": "向右移动", "remove": "为所有人删除", @@ -3149,71 +4065,75 @@ "screenshot": "拍照", "start_audio_stream": "开始音频流" }, - "cookie_warning": "此挂件可能使用 cookie。", - "error_hangup_description": "你已断开通话。(错误:%(message)s)", + "cookie_warning": "此小部件可能会使用 Cookies。", + "error_hangup_description": "通话已中断。(错误:(message)s)", "error_hangup_title": "连接丢失", - "error_loading": "加载挂件时发生错误", - "error_mixed_content": "错误 - 混合内容", - "error_need_invite_permission": "你需要有邀请用户的权限才能进行此操作。", - "error_need_kick_permission": "你需要能够移除用户才能做到那件事。", + "error_loading": "载入小部件时出错", + "error_mixed_content": "错误:混合内容", + "error_need_invite_permission": "你需要能邀请用户才能验证。", + "error_need_kick_permission": "你需要能踢出用户以执行此操作。", "error_need_to_be_logged_in": "你需要登录。", - "error_unable_start_audio_stream_description": "无法开始音频流媒体。", - "error_unable_start_audio_stream_title": "开始流直播失败", - "modal_data_warning": "此屏幕上的数据与%(widgetDomain)s分享", - "modal_title_default": "模态框挂件(Modal Widget)", - "no_name": "未知应用", + "error_unable_start_audio_stream_description": "无法开始音频串流。", + "error_unable_start_audio_stream_title": "无法开始直播", + "modal_data_warning": "以下数据将分享给 %(widgetDomain)s", + "modal_title_default": "模态窗口小部件", + "no_name": "未知 App", "open_id_permissions_dialog": { - "remember_selection": "记住", - "starting_text": "挂件将会验证你的用户 ID,但将无法为你执行动作:", - "title": "允许此挂件验证你的身份" + "remember_selection": "记住我的选择", + "starting_text": "此小部件将验证你的用户 ID,但无法为你执行以下操作:", + "title": "允许此小部件验证你的身份" }, - "popout": "在弹出式窗口中打开挂件", - "set_room_layout": "将我的房间布局设置给所有人", - "shared_data_avatar": "您的个人资料图片URL", - "shared_data_device_id": "你的设备ID", + "popout": "弹出小部件", + "set_room_layout": "为所有人布局", + "shared_data_avatar": "个人资料图像 URL", + "shared_data_device_id": "你的设备 ID", + "shared_data_lang": "你的语言", "shared_data_mxid": "你的用户 ID", "shared_data_name": "你的显示名称", "shared_data_room_id": "房间 ID", "shared_data_theme": "你的主题", "shared_data_url": "%(brand)s 的链接", - "shared_data_warning": "使用此挂件可能会和 %(widgetDomain)s 共享数据 。", - "shared_data_warning_im": "使用此挂件可能会与 %(widgetDomain)s 及您的集成管理器共享数据 。", - "shared_data_widget_id": "挂件 ID", - "unencrypted_warning": "挂件不适用消息加密。", - "unmaximise": "取消最大化", - "unpin_to_view_right_panel": "取消固定此小部件以在此面板中查看" + "shared_data_warning": "使用此小部件可能会与 %(widgetDomain)s 共享数据 。", + "shared_data_warning_im": "使用此小部件可能会与 %(widgetDomain)s 与你的集成管理器共享数据 。", + "shared_data_widget_id": "小部件 ID", + "unencrypted_warning": "小部件不使用消息加密。", + "unmaximise": "还原尺寸", + "unpin_to_view_right_panel": "取消钉住此小部件以在此面板中查看" }, "zxcvbn": { "suggestions": { - "allUppercase": "全大写的密码通常比全小写的更容易猜测", - "anotherWord": "再加一两个词。不常见的词更好。", - "associatedYears": "避免与你相关联的年份", - "capitalization": "大写字母并没有很大的作用", - "dates": "避免与你相关联的日期与年份", - "l33t": "可预见的替换如将 '@' 替换为 'a' 并不会有太大效果", - "longerKeyboardPattern": "使用变化更丰富的字符组合方式", - "noNeed": "不一定要有符号、数字或大写字母", - "recentYears": "避免年份", - "repeated": "避免重复词语与字符", - "reverseWords": "把单词倒过来不会比原来的难猜很多", - "sequences": "避免递增或递减的序列", - "useWords": "用一些字符,避免常用短语" + "allUppercase": "全大写几乎与全小写一样容易被猜测到", + "anotherWord": "再加一两个词。不常用的词更好。", + "associatedYears": "避免与你相关的年份", + "capitalization": "大写字母对密码强度的帮助不大", + "dates": "避免使用与你相关的日期与年份", + "l33t": "像用“@”代替“a”这样可预料的替换行为并没有什么帮助", + "longerKeyboardPattern": "使用更复杂的击键序列", + "noNeed": "不需要符号、数字或大写字母", + "pwned": "如果你在其它地方使用此密码,则应进行更改。", + "recentYears": "避免近些年", + "repeated": "避免重复的单词与字符", + "reverseWords": "颠倒的单词很难被猜测到", + "sequences": "避免使用序列", + "useWords": "使用若干单词,避免常用口令" }, "warnings": { - "common": "这是一个非常常见的密码", - "commonNames": "常用姓名和姓氏很容易被猜到", - "dates": "日期通常很容易被猜到", - "extendedRepeat": "像 “abcabcabc” 这样的重复字符也只比 “abc” 稍微难猜一点点", - "keyPattern": "键位短序列很容易被猜到", - "namesByThemselves": "姓名和姓氏本身很容易被猜到", - "recentYears": "最近的年份很容易被猜到", - "sequences": "像 abc 或 6543 这样的序列很容易被猜到", - "similarToCommon": "这类似于一个常用密码", - "simpleRepeat": "像 “aaa” 这样的重复字符很容易被猜到", - "straightRow": "键位在一条直线上的组合很容易被猜到", - "topHundred": "这是百大常用密码之一", - "topTen": "这是十大常用密码之一", - "wordByItself": "单词本身很容易被猜到" + "common": "此为常见密码", + "commonNames": "常用姓、名容易被猜测到", + "dates": "日期通常容易被猜测到", + "extendedRepeat": "像“abcabcabc”之类的重复单词比“abc”更难猜测到。", + "keyPattern": "简短的击键序列容易被猜测到", + "namesByThemselves": "姓、名容易被猜测到", + "pwned": "你的密码因 Internet 上的数据泄露而暴露。", + "recentYears": "近年来很容易猜到", + "sequences": "像“abc”或“6543”之类的序列容易被猜测到", + "similarToCommon": "这像是常用密码", + "simpleRepeat": "像“aaa”之类的重复的字符容易被猜测到", + "straightRow": "在同一列且连续的字母容易被猜测到", + "topHundred": "这是百大常用密码", + "topTen": "这是十大常用密码", + "userInputs": "不应有任何个人或页面相关数据。", + "wordByItself": "单词很容易猜测到" } } } diff --git a/apps/web/src/i18n/strings/zh_Hant.json b/apps/web/src/i18n/strings/zh_Hant.json index 47079c9d9d..a606287572 100644 --- a/apps/web/src/i18n/strings/zh_Hant.json +++ b/apps/web/src/i18n/strings/zh_Hant.json @@ -1517,7 +1517,6 @@ "restricted": "已限制" }, "powered_by_matrix": "由Matrix支持", - "powered_by_matrix_with_logo": "由 $matrixLogo 驅動的去中心化、加密的聊天與協作工具", "presence": { "away": "離開", "busy": "忙碌", @@ -3405,7 +3404,6 @@ "you_are_presenting": "您正在投影" }, "web_default_device_name": "%(appName)s:%(osName)s 的 %(browserName)s", - "welcome_to_element": "歡迎使用 Element", "widget": { "added_by": "小工具新增者為", "capabilities_dialog": { diff --git a/apps/web/src/languageHandler.tsx b/apps/web/src/languageHandler.tsx index 2effdb3856..a315d61298 100644 --- a/apps/web/src/languageHandler.tsx +++ b/apps/web/src/languageHandler.tsx @@ -12,7 +12,7 @@ import _ from "lodash"; import { _t, normalizeLanguageKey, - type IVariables, + type StringVariables, KEY_SEPARATOR, getLangsJson, registerTranslations, @@ -72,7 +72,7 @@ export class UserFriendlyError extends Error { public constructor( message: TranslationKey, - substitutionVariablesAndCause?: Omit | ErrorOptions, + substitutionVariablesAndCause?: Omit | ErrorOptions, ) { // Prevent "Could not find /%\(cause\)s/g in x" logs to the console by removing it from the list const { cause, ...substitutionVariables } = substitutionVariablesAndCause ?? {}; diff --git a/apps/web/src/models/Call.ts b/apps/web/src/models/Call.ts index 086bb51f0f..6cf95a0681 100644 --- a/apps/web/src/models/Call.ts +++ b/apps/web/src/models/Call.ts @@ -592,6 +592,8 @@ export class JitsiCall extends Call { export enum ElementCallIntent { StartCall = "start_call", JoinExisting = "join_existing", + StartCallVoice = "start_call_voice", + JoinExistingVoice = "join_existing_voice", StartCallDM = "start_call_dm", StartCallDMVoice = "start_call_dm_voice", JoinExistingDM = "join_existing_dm", @@ -685,11 +687,13 @@ export class ElementCall extends Call { params.append("intent", voiceOnly ? ElementCallIntent.StartCallDMVoice : ElementCallIntent.StartCallDM); } } else { - // Group chats do not have a voice option. if (hasCallStarted) { - params.append("intent", ElementCallIntent.JoinExisting); + params.append( + "intent", + voiceOnly ? ElementCallIntent.JoinExistingVoice : ElementCallIntent.JoinExisting, + ); } else { - params.append("intent", ElementCallIntent.StartCall); + params.append("intent", voiceOnly ? ElementCallIntent.StartCallVoice : ElementCallIntent.StartCall); } } } diff --git a/apps/web/src/modules/Api.ts b/apps/web/src/modules/Api.ts index bb3c7497d5..5d9eddfab2 100644 --- a/apps/web/src/modules/Api.ts +++ b/apps/web/src/modules/Api.ts @@ -33,6 +33,8 @@ import { StoresApi } from "./StoresApi.ts"; import { WidgetLifecycleApi } from "./WidgetLifecycleApi.ts"; import { WidgetApi } from "./WidgetApi.ts"; import { CustomisationsApi } from "./customisationsApi.ts"; +import { ComposerApi } from "./ComposerApi.ts"; +import defaultDispatcher from "../dispatcher/dispatcher.ts"; const legacyCustomisationsFactory = (baseCustomisations: T) => { let used = false; @@ -94,6 +96,7 @@ export class ModuleApi implements Api { public readonly rootNode = document.getElementById("matrixchat")!; public readonly client = new ClientApi(); public readonly stores = new StoresApi(); + public readonly composer = new ComposerApi(defaultDispatcher); public createRoot(element: Element): Root { return createRoot(element); diff --git a/apps/web/src/modules/ComposerApi.ts b/apps/web/src/modules/ComposerApi.ts new file mode 100644 index 0000000000..c3cce62485 --- /dev/null +++ b/apps/web/src/modules/ComposerApi.ts @@ -0,0 +1,23 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type ComposerApi as ModuleComposerApi } from "@element-hq/element-web-module-api"; + +import type { MatrixDispatcher } from "../dispatcher/dispatcher"; +import { Action } from "../dispatcher/actions"; +import type { ComposerInsertPayload } from "../dispatcher/payloads/ComposerInsertPayload"; + +export class ComposerApi implements ModuleComposerApi { + public constructor(private readonly dispatcher: MatrixDispatcher) {} + + public insertPlaintextIntoComposer(plaintext: string): void { + this.dispatcher.dispatch({ + action: Action.ComposerInsert, + text: plaintext, + } satisfies ComposerInsertPayload); + } +} diff --git a/apps/web/src/renderer/utils.tsx b/apps/web/src/renderer/utils.tsx index 4ebbd0b365..303d7cbf11 100644 --- a/apps/web/src/renderer/utils.tsx +++ b/apps/web/src/renderer/utils.tsx @@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details. */ import { type JSX } from "react"; -import { type DOMNode, Element, type HTMLReactParserOptions, type Text } from "html-react-parser"; +import { type DOMNode, type Element, type HTMLReactParserOptions, type Text } from "html-react-parser"; import { type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix"; /** @@ -89,7 +89,7 @@ export const combineRenderers = if (result) return result; } } - if (node instanceof Element) { + if (node.type === "tag") { const tagName = node.tagName.toLowerCase() as keyof HTMLElementTagNameMap; for (const replacer of renderers) { const result = replacer[tagName]?.(node, parametersWithReplace, index); diff --git a/apps/web/src/settings/Settings.tsx b/apps/web/src/settings/Settings.tsx index b70095125f..67d0a31e14 100644 --- a/apps/web/src/settings/Settings.tsx +++ b/apps/web/src/settings/Settings.tsx @@ -52,6 +52,7 @@ import InviteRulesConfigController from "./controllers/InviteRulesConfigControll import { type ComputedInviteConfig } from "../@types/invite-rules.ts"; import BlockInvitesConfigController from "./controllers/BlockInvitesConfigController.ts"; import RequiresSettingsController from "./controllers/RequiresSettingsController.ts"; +import { type OrderedCustomSections, type CustomSectionsData } from "../stores/room-list-v3/section.ts"; export const defaultWatchManager = new WatchManager(); @@ -211,7 +212,6 @@ export interface Settings { "feature_mjolnir": IFeature; "feature_custom_themes": IFeature; "feature_exclude_insecure_devices": IFeature; - "feature_share_history_on_invite": IFeature; "feature_html_topic": IFeature; "feature_bridge_state": IFeature; "feature_jump_to_date": IFeature; @@ -373,6 +373,8 @@ export interface Settings { "inviteRules": IBaseSetting; "blockInvites": IBaseSetting; "Developer.elementCallUrl": IBaseSetting; + "RoomList.CustomSectionData": IBaseSetting; + "RoomList.OrderedCustomSections": IBaseSetting; } export type SettingKey = keyof Settings; @@ -519,29 +521,6 @@ export const SETTINGS: Settings = { supportedLevelsAreOrdered: true, default: false, }, - "feature_share_history_on_invite": { - isFeature: true, - labsGroup: LabGroup.Encryption, - displayName: _td("labs|share_history_on_invite"), - description: () => ( - <> - {_t("labs|share_history_on_invite_description")} -
- {_t( - "settings|warning", - {}, - { - w: (sub) => {sub}, - description: _t("labs|share_history_on_invite_warning"), - }, - )} -
- - ), - supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED, - supportedLevelsAreOrdered: true, - default: false, - }, // Defaulted to true Feb 26, intention is to remove entirely, all being well, // as this fixes bugs where display name / avatar are missing and also makes // Element Web consistent with Element X. @@ -1371,6 +1350,22 @@ export const SETTINGS: Settings = { supportedLevels: LEVELS_ACCOUNT_SETTINGS, default: {}, }, + /** + * Managed by the {@link RoomListStoreV3} + * Store the custom section data for the room list + */ + "RoomList.CustomSectionData": { + supportedLevels: LEVELS_ACCOUNT_SETTINGS, + default: {}, + }, + /** + * Managed by the {@link RoomListStoreV3} + * Store the ordering of the custom sections for the room list + */ + "RoomList.OrderedCustomSections": { + supportedLevels: LEVELS_ACCOUNT_SETTINGS, + default: [], + }, [UIFeature.RoomHistorySettings]: { supportedLevels: LEVELS_UI_FEATURE, default: true, diff --git a/apps/web/src/stores/RoomViewStore.tsx b/apps/web/src/stores/RoomViewStore.tsx index 7d06391e32..f875d2f26d 100644 --- a/apps/web/src/stores/RoomViewStore.tsx +++ b/apps/web/src/stores/RoomViewStore.tsx @@ -544,11 +544,9 @@ export class RoomViewStore extends EventEmitter { const joinOpts: IJoinRoomOpts = { viaServers, + acceptSharedHistory: true, ...(payload.opts ?? {}), }; - if (SettingsStore.getValue("feature_share_history_on_invite")) { - joinOpts.acceptSharedHistory = true; - } try { const cli = MatrixClientPeg.safeGet(); await retry( diff --git a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts index 71c5a1a9cb..60b86ddc81 100644 --- a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts @@ -40,6 +40,7 @@ import { DefaultTagID } from "./skip-list/tag"; import { ExcludeTagsFilter } from "./skip-list/filters/ExcludeTagsFilter"; import { TagFilter } from "./skip-list/filters/TagFilter"; import { filterBoolean } from "../../utils/arrays"; +import { createSection, deleteSection, editSection } from "./section"; /** * These are the filters passed to the room skip list. @@ -59,6 +60,10 @@ export enum RoomListStoreV3Event { ListsUpdate = "lists_update", // The event which is called when the room list is loaded. ListsLoaded = "lists_loaded", + /** Fired when a new section is created in the room list. */ + SectionCreated = "section_created", + /** Fired when a room's tags change. */ + RoomTagged = "room_tagged", } // The result object for returning rooms from the store @@ -89,6 +94,9 @@ export const CHATS_TAG = "chats"; export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate; export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded; +export const SECTION_CREATED_EVENT = RoomListStoreV3Event.SectionCreated; +export const ROOM_TAGGED_EVENT = RoomListStoreV3Event.RoomTagged; + /** * This store allows for fast retrieval of the room list in a sorted and filtered manner. * This is the third such implementation hence the "V3". @@ -108,7 +116,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { /** * Defines the display order of sections. */ - private readonly sortedTags: string[] = [DefaultTagID.Favourite, CHATS_TAG, DefaultTagID.LowPriority]; + private sortedTags: string[] = []; private readonly msc3946ProcessDynamicPredecessor: boolean; @@ -125,6 +133,8 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { this.onActiveSpaceChanged(); }); SpaceStore.instance.on(UPDATE_HOME_BEHAVIOUR, () => this.onActiveSpaceChanged()); + SettingsStore.watchSetting("RoomList.OrderedCustomSections", null, () => this.onOrderedCustomSectionsChange()); + this.loadCustomSections(); } /** @@ -236,6 +246,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { case "MatrixActions.Room.tags": { const room = payload.room; this.addRoomAndEmit(room); + this.emit(ROOM_TAGGED_EVENT); break; } @@ -463,6 +474,63 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { }; }); } + + /** + * Handle changes to the order of custom sections. + * Reloads the custom sections, updates the skip list filters to reflect the new order and emits an update. + * Emit {@link LISTS_UPDATE_EVENT}. + */ + private onOrderedCustomSectionsChange(): void { + this.loadCustomSections(); + if (!this.roomSkipList) return; + this.roomSkipList.useNewFilters(this.getSkipListFilters()); + this.scheduleEmit(); + } + + /** + * Create a new section. + * Emits {@link SECTION_CREATED_EVENT} if the section was successfully created. + */ + public async createSection(): Promise { + const tag = await createSection(); + if (!tag) return; + this.emit(SECTION_CREATED_EVENT, tag); + return tag; + } + + /** + * Edit a section's name. + * @param tag The tag of the section to edit + */ + public async editSection(tag: string): Promise { + await editSection(tag); + } + + /** + * Remove a section + * Emits {@link LISTS_UPDATE_EVENT} if the section was successfully removed. + * @param tag The tag of the section to remove + * @param isEmpty Whether the section is empty + */ + public async removeSection(tag: string, isEmpty: boolean): Promise { + await deleteSection(tag, isEmpty); + this.scheduleEmit(); + } + + /** + * Returns the ordered section tags. + */ + public get orderedSectionTags(): string[] { + return this.sortedTags; + } + + /** + * Load the custom sections from the settings store and update the sorted tags. + */ + private loadCustomSections(): void { + const orderedCustomSections = SettingsStore.getValue("RoomList.OrderedCustomSections"); + this.sortedTags = [DefaultTagID.Favourite, ...orderedCustomSections, CHATS_TAG, DefaultTagID.LowPriority]; + } } export default class RoomListStoreV3 { diff --git a/apps/web/src/stores/room-list-v3/section.ts b/apps/web/src/stores/room-list-v3/section.ts new file mode 100644 index 0000000000..5cd4f97169 --- /dev/null +++ b/apps/web/src/stores/room-list-v3/section.ts @@ -0,0 +1,123 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { logger } from "matrix-js-sdk/src/logger"; + +import { SettingLevel } from "../../settings/SettingLevel"; +import SettingsStore from "../../settings/SettingsStore"; +import Modal from "../../Modal"; +import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectionDialog"; +import { RemoveSectionDialog } from "../../components/views/dialogs/RemoveSectionDialog"; + +type Tag = string; + +/** + * Prefix for custom section tags. + */ +export const CUSTOM_SECTION_TAG_PREFIX = "element.io.section."; + +/** + * Checks if a given tag is a custom section tag. + * @param tag - The tag to check. + * @returns True if the tag is a custom section tag, false otherwise. + */ +export function isCustomSectionTag(tag: string): boolean { + return tag.startsWith(CUSTOM_SECTION_TAG_PREFIX); +} + +/** + * Structure of the custom section stored in the settings. The tag is used as a unique identifier for the section, and the name is given by the user. + */ +type CustomSection = { + tag: Tag; + name: string; +}; + +/** + * The custom sections data is stored as a record in the settings, where the key is the section tag and the value is the section data (name and tag). + */ +export type CustomSectionsData = Record; +/** + * Ordered list of custom section tags. + */ +export type OrderedCustomSections = Tag[]; + +/** + * Creates a new custom section by showing a dialog to the user to enter the section name. + * If the user confirms, it generates a unique tag for the section, saves the section data in the settings, and updates the ordered list of sections. + * + * @return A promise that resolves to the new section tag if created, or undefined if cancelled. + */ +export async function createSection(): Promise { + const modal = Modal.createDialog(CreateSectionDialog); + + const [shouldCreateSection, sectionName] = await modal.finished; + if (!shouldCreateSection || !sectionName) return undefined; + + const tag = `${CUSTOM_SECTION_TAG_PREFIX}${window.crypto.randomUUID()}`; + const newSection: CustomSection = { tag, name: sectionName }; + + // Save the new section data + const sectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {}; + sectionData[tag] = newSection; + await SettingsStore.setValue("RoomList.CustomSectionData", null, SettingLevel.ACCOUNT, sectionData); + + // Add the new section to the ordered list of sections + const orderedSections = SettingsStore.getValue("RoomList.OrderedCustomSections") || []; + orderedSections.push(tag); + await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, orderedSections); + return tag; +} + +/** + * Edits an existing custom section by showing a dialog to the user to enter the new section name. If the user confirms, it updates the section data in the settings. + * @param tag - The tag of the section to edit. + */ +export async function editSection(tag: string): Promise { + const sectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {}; + const section = sectionData[tag]; + if (!section) { + logger.info("Unknown section tag, cannot edit section", tag); + return; + } + + const modal = Modal.createDialog(CreateSectionDialog, { sectionToEdit: section.name }); + + const [shouldEditSection, newName] = await modal.finished; + const isSameName = newName === section.name; + if (!shouldEditSection || !newName || isSameName) return; + + // Save the new name + sectionData[tag].name = newName; + await SettingsStore.setValue("RoomList.CustomSectionData", null, SettingLevel.ACCOUNT, sectionData); +} + +/** + * Deletes a custom section by showing a confirmation dialog to the user. If the user confirms, it removes the section data from the settings and updates the ordered list of sections. + * @param tag - The tag of the section to delete. + * @param isEmpty - Whether the section is empty (has no rooms). If the section is not empty, the confirmation dialog will show a warning message. + */ +export async function deleteSection(tag: string, isEmpty: boolean): Promise { + const sectionData = SettingsStore.getValue("RoomList.CustomSectionData"); + if (!sectionData[tag]) { + logger.info("Unknown section tag, cannot delete section", tag); + return; + } + + const modal = Modal.createDialog(RemoveSectionDialog, { isEmpty }); + const [shouldRemoveSection] = await modal.finished; + if (!shouldRemoveSection) return; + + // Remove the section from the ordered list of sections + const orderedSections = SettingsStore.getValue("RoomList.OrderedCustomSections"); + const newOrderedSections = orderedSections.filter((sectionTag) => sectionTag !== tag); + await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, newOrderedSections); + + // Remove the section data + delete sectionData[tag]; + await SettingsStore.setValue("RoomList.CustomSectionData", null, SettingLevel.ACCOUNT, sectionData); +} diff --git a/apps/web/src/stores/room-list-v3/skip-list/RoomSkipList.ts b/apps/web/src/stores/room-list-v3/skip-list/RoomSkipList.ts index 93c898ee21..dfa5b678e0 100644 --- a/apps/web/src/stores/room-list-v3/skip-list/RoomSkipList.ts +++ b/apps/web/src/stores/room-list-v3/skip-list/RoomSkipList.ts @@ -76,6 +76,17 @@ export class RoomSkipList implements Iterable { this.seed(rooms); } + /** + * Change the filters used by the skip list. + * This will apply the new filters to all existing nodes. + */ + public useNewFilters(filters: Filter[]): void { + this.filters = filters; + for (const node of this.roomNodeMap.values()) { + node.applyFilters(this.filters); + } + } + /** * Removes a given room from the skip list. */ diff --git a/apps/web/src/toasts/IncomingCallToast.tsx b/apps/web/src/toasts/IncomingCallToast.tsx index f1ca624a59..95f456431f 100644 --- a/apps/web/src/toasts/IncomingCallToast.tsx +++ b/apps/web/src/toasts/IncomingCallToast.tsx @@ -6,7 +6,17 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX, useCallback, useEffect, useRef, useState } from "react"; +import React, { + type JSX, + type ReactNode, + type ComponentType, + type SVGAttributes, + useCallback, + useEffect, + useRef, + useState, + useId, +} from "react"; import { type Room, type MatrixEvent, @@ -15,11 +25,16 @@ import { EventType, MatrixEventEvent, } from "matrix-js-sdk/src/matrix"; -import { Button, ToggleInput, Tooltip, TooltipProvider } from "@vector-im/compound-web"; -import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid"; +import { AvatarStack, Button, Form, Heading, InlineField, Label, ToggleInput, Tooltip } from "@vector-im/compound-web"; import { logger } from "matrix-js-sdk/src/logger"; import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc"; -import { CheckIcon, VoiceCallIcon, CloseIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { + CheckIcon, + CloseIcon, + ExpandIcon, + VideoCallSolidIcon, + VoiceCallSolidIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; import { AvatarWithDetails } from "@element-hq/web-shared-components"; import { _t } from "../languageHandler"; @@ -29,8 +44,7 @@ import defaultDispatcher from "../dispatcher/dispatcher"; import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; import { Action } from "../dispatcher/actions"; import ToastStore from "../stores/ToastStore"; -import { LiveContentSummary, LiveContentType } from "../components/views/rooms/LiveContentSummary"; -import { useCall, useParticipantCount } from "../hooks/useCall"; +import { useCall, useParticipatingMembers } from "../hooks/useCall"; import AccessibleButton, { type ButtonEvent } from "../components/views/elements/AccessibleButton"; import { useDispatcher } from "../hooks/useDispatcher"; import { type ActionPayload } from "../dispatcher/payloads"; @@ -39,6 +53,7 @@ import LegacyCallHandler, { AudioID } from "../LegacyCallHandler"; import { useEventEmitter, useTypedEventEmitter } from "../hooks/useEventEmitter"; import { CallStore, CallStoreEvent } from "../stores/CallStore"; import DMRoomMap from "../utils/DMRoomMap"; +import MemberAvatar from "../components/views/avatars/MemberAvatar"; /** * Get the key for the incoming call toast. A combination of the call ID and room ID. @@ -76,20 +91,24 @@ interface JoinCallButtonWithCallProps { isRinging: boolean; } -function JoinCallButtonWithCall({ onClick, disabledTooltip, isRinging }: JoinCallButtonWithCallProps): JSX.Element { - return ( - - - +function JoinCallButtonWithCall({ onClick, disabledTooltip }: JoinCallButtonWithCallProps): JSX.Element { + const button = ( + + ); + + return disabledTooltip === undefined ? ( + button + ) : ( + {button} ); } @@ -115,19 +134,16 @@ function DeclineCallButtonWithNotificationEvent({ [notificationEvent, onDeclined, room?.client, room?.roomId], ); return ( - - - + ); } @@ -210,7 +226,7 @@ export function IncomingCallToast({ notificationEvent, toastKey }: Props): JSX.E // Dismiss if another device from this user joins. const onParticipantChange = useCallback( - (participants: Map>, prevParticipants: Map>) => { + (participants: Map>) => { if (Array.from(participants.keys()).some((p) => p.userId == room?.client.getUserId())) { dismissToast(); } @@ -238,32 +254,33 @@ export function IncomingCallToast({ notificationEvent, toastKey }: Props): JSX.E ), ); - const [skipLobbyToggle, setSkipLobbyToggle] = useState(true); + const [videoToggle, setVideoToggle] = useState(true); + const videoToggleId = useId(); - // Dismiss on clicking join. - // If the skip lobby option is undefined, it will use to the shift key state to decide if the lobby is skipped. - const onJoinClick = useCallback( - (e: ButtonEvent): void => { - e.stopPropagation(); + const isVoice = notificationContent["m.call.intent"] === "audio"; + const viewCall = useCallback( + (skipLobby: boolean) => { // The toast will be automatically dismissed by the dispatcher callback above defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: room?.roomId, view_call: true, - skipLobby: ("shiftKey" in e && e.shiftKey) || skipLobbyToggle, - voiceOnly: notificationContent["m.call.intent"] === "audio", + skipLobby, + voiceOnly: isVoice || !videoToggle, metricsTrigger: undefined, }); }, - [room, skipLobbyToggle, notificationContent], + [room, isVoice, videoToggle], ); + const onJoinClick = useCallback(() => viewCall(true), [viewCall]); + const onExpandClick = useCallback(() => viewCall(false), [viewCall]); + // Dismiss on closing toast. const onCloseClick = useCallback( (e: ButtonEvent): void => { e.stopPropagation(); - dismissToast(); }, [dismissToast], @@ -272,75 +289,101 @@ export function IncomingCallToast({ notificationEvent, toastKey }: Props): JSX.E useEventEmitter(CallStore.instance, CallStoreEvent.Call, onCall); useEventEmitter(call ?? undefined, CallEvent.Participants, onParticipantChange); useEventEmitter(room, RoomEvent.Timeline, onTimelineChange); - const isVoice = notificationContent["m.call.intent"] === "audio"; + const otherUserId = DMRoomMap.shared().getUserIdForRoomId(roomId); - const participantCount = useParticipantCount(call); - const detailsInformation = - notificationContent.notification_type === "ring" ? ( - {otherUserId} - ) : ( - - ); + const members = useParticipatingMembers(call); + const avatars = (): ReactNode => ( + + {members.slice(0, 3).map((m) => ( + + ))} + + ); + + let detailsInformation: ReactNode; + if (notificationContent.notification_type === "ring") { + detailsInformation = {otherUserId}; + } else if (members.length > 0) { + detailsInformation = + members.length > 3 + ? _t( + "voip|call_members|overflow", + { count: members.length, overflowCount: members.length - 3 }, + { avatars }, + ) + : _t("voip|call_members|exhaustive", { count: members.length }, { avatars }); + } + + let title: string; + let Icon: ComponentType>; + let iconLabel: string; + // Special title for group calls + if (otherUserId === undefined) title = _t("voip|group_call_started"); + if (isVoice) { + title ??= _t("voip|voice_call_incoming"); + Icon = VoiceCallSolidIcon; + iconLabel = _t("voip|voice_call"); + } else { + title ??= _t("voip|video_call_incoming"); + Icon = VideoCallSolidIcon; + iconLabel = _t("voip|video_call"); + } return ( - - <> -
- {isVoice ? ( -
- {" "} - {_t("voip|voice_call_incoming")} -
- ) : ( -
- {" "} - {notificationContent.notification_type === "ring" - ? _t("voip|video_call_incoming") - : _t("voip|video_call_started")} -
- )} - } - details={detailsInformation} - title={room ? room.name : _t("voip|call_toast_unknown_room")} - className="mx_IncomingCallToast_AvatarWithDetails" - /> - {!isVoice && ( -
- {_t("voip|skip_lobby_toggle_option")} - setSkipLobbyToggle(e.target.checked)} - checked={skipLobbyToggle} - /> -
- )} -
- - -
-
+
+
+ + + {title} + - + - - +
+ } + details={detailsInformation} + title={room ? room.name : _t("voip|call_toast_unknown_room")} + className="mx_IncomingCallToast_AvatarWithDetails" + /> + {!isVoice && ( + { + evt.preventDefault(); + evt.stopPropagation(); + }} + > + setVideoToggle(e.target.checked)} + /> + } + > + + + + )} +
+ + +
+
); } diff --git a/apps/web/src/utils/DialogOpener.ts b/apps/web/src/utils/DialogOpener.ts index 9eadf63adf..fff7297a68 100644 --- a/apps/web/src/utils/DialogOpener.ts +++ b/apps/web/src/utils/DialogOpener.ts @@ -57,6 +57,7 @@ export class DialogOpener { { roomId: payload.room_id || SdkContextClass.instance.roomViewStore.getRoomId(), initialTabId: payload.initial_tab_id, + sdkContext: SdkContextClass.instance, }, /*className=*/ undefined, /*isPriority=*/ false, diff --git a/apps/web/src/utils/MultiInviter.ts b/apps/web/src/utils/MultiInviter.ts index 46fd84cc26..09ae6adb9c 100644 --- a/apps/web/src/utils/MultiInviter.ts +++ b/apps/web/src/utils/MultiInviter.ts @@ -228,10 +228,10 @@ export default class MultiInviter { } } - const opts: InviteOpts = {}; + const opts: InviteOpts = { + shareEncryptedHistory: true, + }; if (this.reason !== undefined) opts.reason = this.reason; - if (SettingsStore.getValue("feature_share_history_on_invite")) opts.shareEncryptedHistory = true; - return this.matrixClient.invite(roomId, addr, opts); } else { throw new Error("Unsupported address"); diff --git a/apps/web/src/utils/SessionLock.ts b/apps/web/src/utils/SessionLock.ts index c93097aba1..afa18c2644 100644 --- a/apps/web/src/utils/SessionLock.ts +++ b/apps/web/src/utils/SessionLock.ts @@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details. */ import { logger } from "matrix-js-sdk/src/logger"; -import { v4 as uuidv4 } from "uuid"; /* * Functionality for checking that only one instance is running at once @@ -107,7 +106,7 @@ export function checkSessionLockFree(): boolean { */ export async function getSessionLock(onNewInstance: () => Promise): Promise { /** unique ID for this session */ - const sessionIdentifier = uuidv4(); + const sessionIdentifier = window.crypto.randomUUID(); const prefixedLogger = logger.getChild(`getSessionLock[${sessionIdentifier}]`); diff --git a/apps/web/src/utils/form.ts b/apps/web/src/utils/form.ts new file mode 100644 index 0000000000..21017c393e --- /dev/null +++ b/apps/web/src/utils/form.ts @@ -0,0 +1,17 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import type React from "react"; + +/** + * onSubmit handler which calls preventDefault and stopPropagation on the event + * @param e submit event + */ +export function onSubmitPreventDefault(e: SubmitEvent | React.SubmitEvent): void { + e.preventDefault(); + e.stopPropagation(); +} diff --git a/apps/web/src/utils/leave-behaviour.ts b/apps/web/src/utils/leave-behaviour.ts index 8b7ee02f36..9756fa50cf 100644 --- a/apps/web/src/utils/leave-behaviour.ts +++ b/apps/web/src/utils/leave-behaviour.ts @@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details. import { sleep } from "matrix-js-sdk/src/utils"; import React, { type ReactNode } from "react"; import { EventStatus, MatrixEventEvent, type Room, type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; import Modal, { type IHandle } from "../Modal"; import Spinner from "../components/views/elements/Spinner"; @@ -25,6 +26,8 @@ import { type AfterLeaveRoomPayload } from "../dispatcher/payloads/AfterLeaveRoo import { bulkSpaceBehaviour } from "./space"; import { SdkContextClass } from "../contexts/SDKContext"; import SettingsStore from "../settings/SettingsStore"; +import { CallStore } from "../stores/CallStore"; +import LegacyCallHandler from "../LegacyCallHandler"; export async function leaveRoomBehaviour( matrixClient: MatrixClient, @@ -59,6 +62,23 @@ export async function leaveRoomBehaviour( throw new Error(`Expected to find room for id ${roomId}`); } + // attempt to hang up legacy based calls + try { + LegacyCallHandler.instance.hangupOrReject(roomId); + } catch (e) { + logger.warn("Failed to hangup call before leaving room: ", e); + } + + // hang up widget based calls + const activeCall = CallStore.instance.getActiveCall(roomId); + if (activeCall) { + try { + await activeCall.disconnect(); + } catch (e) { + logger.warn("Failed to disconnect call before leaving room: ", e); + } + } + // await any queued messages being sent so that they do not fail await Promise.all( room diff --git a/apps/web/src/utils/oidc/authorize.ts b/apps/web/src/utils/oidc/authorize.ts index b0020446ac..2e1420e32e 100644 --- a/apps/web/src/utils/oidc/authorize.ts +++ b/apps/web/src/utils/oidc/authorize.ts @@ -23,6 +23,7 @@ import { type URLParams } from "../../vector/url_utils.ts"; * @param clientId this client's id as registered with configured issuer * @param homeserverUrl target homeserver * @param identityServerUrl OPTIONAL target identity server + * @param isRegistration if true will set the prompt to "create" * @returns Promise that resolves after we have navigated to auth endpoint */ export const startOidcLogin = async ( @@ -47,7 +48,7 @@ export const startOidcLogin = async ( nonce, prompt, urlState: PlatformPeg.get()?.getOidcClientState(), - responseMode: "fragment", + responseMode: delegatedAuthConfig.response_modes_supported?.includes("fragment") ? "fragment" : "query", }); window.location.href = authorizationUrl; @@ -57,15 +58,20 @@ export const startOidcLogin = async ( * Gets `code` and `state` response params * * @param urlParams - the parameters to read + * @param responseMode - the response_mode used in the auth request * @returns code and state * @throws when code and state are not valid strings */ -const getCodeAndStateFromParams = ({ - code, - state, -}: NonNullable): { code: string; state: string } => { +const getCodeAndStateFromParams = ( + { code, state }: NonNullable, + responseMode: "fragment" | "query", +): { code: string; state: string } => { if (!code || typeof code !== "string" || !state || typeof state !== "string") { - throw new Error(OidcClientError.InvalidQueryParameters); + if (responseMode === "fragment") { + throw new Error(OidcClientError.InvalidFragmentParameters); + } else { + throw new Error(OidcClientError.InvalidQueryParameters); + } } return { code, state }; }; @@ -91,15 +97,17 @@ type CompleteOidcLoginResponse = { /** * Attempt to complete authorization code flow to get an access token * @param urlParams the parameters extracted from the app-load URI. + * @param responseMode - the response_mode used in the auth request * @returns Promise that resolves with a CompleteOidcLoginResponse when login was successful * @throws When we failed to get a valid access token */ export const completeOidcLogin = async ( - urlParams: NonNullable, + urlParams: NonNullable, + responseMode: "fragment" | "query", ): Promise => { - const { code, state } = getCodeAndStateFromParams(urlParams); + const { code, state } = getCodeAndStateFromParams(urlParams, responseMode); const { homeserverUrl, tokenResponse, idTokenClaims, identityServerUrl, oidcClientSettings } = - await completeAuthorizationCodeGrant(code, state, "fragment"); + await completeAuthorizationCodeGrant(code, state, responseMode); return { homeserverUrl, diff --git a/apps/web/src/utils/oidc/error.ts b/apps/web/src/utils/oidc/error.ts index f9334a739c..3cc5c14ec5 100644 --- a/apps/web/src/utils/oidc/error.ts +++ b/apps/web/src/utils/oidc/error.ts @@ -17,6 +17,7 @@ import { _t } from "../../languageHandler"; */ export enum OidcClientError { InvalidQueryParameters = "Invalid query parameters for OIDC native login. `code` and `state` are required.", + InvalidFragmentParameters = "Invalid fragment parameters for OIDC native login. `code` and `state` are required.", } /** @@ -30,6 +31,7 @@ export const getOidcErrorMessage = (error: Error): string | ReactNode => { case OidcError.MissingOrInvalidStoredState: return _t("auth|oidc|missing_or_invalid_stored_state"); case OidcClientError.InvalidQueryParameters: + case OidcClientError.InvalidFragmentParameters: case OidcError.CodeExchangeFailed: case OidcError.InvalidBearerTokenResponse: case OidcError.InvalidIdToken: diff --git a/apps/web/src/utils/room/tagRoom.ts b/apps/web/src/utils/room/tagRoom.ts index ae9b52a174..62bac2ffca 100644 --- a/apps/web/src/utils/room/tagRoom.ts +++ b/apps/web/src/utils/room/tagRoom.ts @@ -13,20 +13,29 @@ import { DefaultTagID, type TagID } from "../../stores/room-list-v3/skip-list/ta import RoomListActions from "../../actions/RoomListActions"; import dis from "../../dispatcher/dispatcher"; import { getTagsForRoom } from "./getTagsForRoom"; +import { isCustomSectionTag } from "../../stores/room-list-v3/section"; /** - * Toggle tag for a given room + * Toggle tag for a given room. + * A room can only be in one section: either a custom section, Favourite, or LowPriority. + * Applying any of these will atomically replace the current section tag. * @param room The room to tag * @param tagId The tag to invert */ export function tagRoom(room: Room, tagId: TagID): void { - if (tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority) { - const inverseTag = tagId === DefaultTagID.Favourite ? DefaultTagID.LowPriority : DefaultTagID.Favourite; - const isApplied = getTagsForRoom(room).includes(tagId); - const removeTag = isApplied ? tagId : inverseTag; - const addTag = isApplied ? null : tagId; - dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag)); - } else { + if (tagId !== DefaultTagID.Favourite && tagId !== DefaultTagID.LowPriority && !isCustomSectionTag(tagId)) { logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`); + return; } + + // Find the section tag currently applied (Fav, LowPriority, or custom) — at most one exists + const currentSectionTag = + getTagsForRoom(room).find( + (t) => t === DefaultTagID.Favourite || t === DefaultTagID.LowPriority || isCustomSectionTag(t), + ) ?? null; + + const isApplied = currentSectionTag === tagId; + const removeTag = currentSectionTag; + const addTag = isApplied ? null : tagId; + dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag)); } diff --git a/apps/web/src/vector/app.tsx b/apps/web/src/vector/app.tsx index fa31bdd732..db5d0e4e99 100644 --- a/apps/web/src/vector/app.tsx +++ b/apps/web/src/vector/app.tsx @@ -44,7 +44,7 @@ function onTokenLoginCompleted(urlParams: URLParams, fragmentAfterLogin: string) const url = new URL(window.location.href); // if we did a token login, we're now left with the login token as query param in the url; clear it out - for (const param in { ...urlParams.legacy_sso }) { + for (const param in { ...urlParams.legacy_sso, ...urlParams.oidc_query }) { url.searchParams.delete(param); } @@ -112,7 +112,7 @@ export async function loadApp(urlParams: URLParams, matrixChatRef: React.Ref PreviewVisibility.MediaHidden) { const media = mediaFromMxc(preview["og:image"], this.client); const declaredHeight = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["og:image:height"]); const declaredWidth = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["og:image:width"]); - const width = Math.min(declaredWidth ?? PREVIEW_WIDTH, PREVIEW_WIDTH); - const height = thumbHeight(width, declaredHeight, PREVIEW_WIDTH, PREVIEW_WIDTH) ?? PREVIEW_WIDTH; - const thumb = media.getThumbnailOfSourceHttp(PREVIEW_WIDTH, PREVIEW_HEIGHT, "scale"); - // No thumb, no preview. - if (thumb) { - image = { - imageThumb: thumb, - imageFull: media.srcHttp ?? thumb, - width, - height, - fileSize: UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]), - }; + const imageSize = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]); + const alt = typeof preview["og:image:alt"] === "string" ? preview["og:image:alt"] : undefined; + + const isImagePreview = UrlPreviewGroupViewModel.isImagePreview(declaredWidth, declaredHeight, imageSize); + if (isImagePreview) { + const width = Math.min(declaredWidth ?? PREVIEW_WIDTH_PX, PREVIEW_WIDTH_PX); + const height = + thumbHeight(width, declaredHeight, PREVIEW_WIDTH_PX, PREVIEW_WIDTH_PX) ?? PREVIEW_WIDTH_PX; + const thumb = media.getThumbnailOfSourceHttp(PREVIEW_WIDTH_PX, PREVIEW_HEIGHT_PX, "scale"); + const playable = !!preview["og:video"] || !!preview["og:video:type"] || !!preview["og:audio"]; + // No thumb, no preview. + if (thumb) { + image = { + imageThumb: thumb, + imageFull: media.srcHttp ?? thumb, + width, + height, + fileSize: UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]), + alt, + playable, + }; + } + } else if (media.srcHttp) { + siteIcon = media.srcHttp; } } const result = { link, title, + author, description, siteName, - showTooltipOnLink: link !== title && PlatformPeg.get()?.needsUrlTooltips(), + siteIcon, + showTooltipOnLink: !!(link !== title && PlatformPeg.get()?.needsUrlTooltips()), image, } satisfies UrlPreview; this.previewCache.set(link, result); @@ -357,7 +424,7 @@ export class UrlPreviewGroupViewModel * Trigger a recalculation of the links in an event. * @param eventElement */ - public async updateEventElement(eventElement: HTMLDivElement): Promise { + public async updateEventElement(eventElement: HTMLDivElement | HTMLSpanElement): Promise { const newLinks = UrlPreviewGroupViewModel.findLinks([eventElement]); // Only recalculate if the set of links has changed. if (newLinks.some((x) => !this.links.includes(x)) || this.links.some((x) => !newLinks.includes(x))) { diff --git a/apps/web/src/viewmodels/room-list/RoomListHeaderViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListHeaderViewModel.ts index 8a5547e6e9..55626cb252 100644 --- a/apps/web/src/viewmodels/room-list/RoomListHeaderViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListHeaderViewModel.ts @@ -199,8 +199,11 @@ export class RoomListHeaderViewModel SettingsStore.setValue("RoomList.showMessagePreview", null, SettingLevel.DEVICE, isMessagePreviewEnabled); this.snapshot.merge({ isMessagePreviewEnabled }); }; -} + public createSection = (): void => { + RoomListStoreV3.instance.createSection(); + }; +} /** * Get the initial snapshot for the RoomListHeaderViewModel. * @param spaceStore - The space store instance. @@ -272,6 +275,10 @@ function computeHeaderSpaceState( ); const canAccessSpaceSettings = Boolean(activeSpace && shouldShowSpaceSettings(activeSpace)); + const isSectionFeatureEnabled = SettingsStore.getValue("feature_room_list_sections"); + const useComposeIcon = !isSectionFeatureEnabled; + const canCreateSection = isSectionFeatureEnabled; + return { title, canCreateRoom, @@ -280,5 +287,7 @@ function computeHeaderSpaceState( displaySpaceMenu, canInviteInSpace, canAccessSpaceSettings, + canCreateSection, + useComposeIcon, }; } diff --git a/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts index b2f24da66d..b247ade51c 100644 --- a/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts @@ -10,6 +10,7 @@ import { RoomNotifState, type RoomListItemViewSnapshot, type RoomListItemViewActions, + type Section, } from "@element-hq/web-shared-components"; import { RoomEvent } from "matrix-js-sdk/src/matrix"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; @@ -37,6 +38,8 @@ import { Action } from "../../dispatcher/actions"; import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import PosthogTrackers from "../../PosthogTrackers"; import { type Call, CallEvent } from "../../models/Call"; +import RoomListStoreV3, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3"; +import { _t } from "../../languageHandler"; interface RoomItemProps { room: Room; @@ -95,6 +98,13 @@ export class RoomListItemViewModel this.disposables.trackListener(props.room, RoomEvent.Name, this.onRoomChanged); this.disposables.trackListener(props.room, RoomEvent.Tags, this.onRoomChanged); + const orderSectionsRef = SettingsStore.watchSetting("RoomList.OrderedCustomSections", null, () => + this.onOrderedCustomSectionsChange(), + ); + this.disposables.track(() => { + SettingsStore.unwatchSetting(orderSectionsRef); + }); + // Load message preview asynchronously (sync data is already complete) void this.loadAndSetMessagePreview(); } @@ -180,6 +190,7 @@ export class RoomListItemViewModel this.snapshot.merge({ ...newItem, notification: keepIfSame(this.snapshot.current.notification, newItem.notification), + sections: keepIfSame(this.snapshot.current.sections, newItem.sections), // Preserve message preview - it's managed separately by loadAndSetMessagePreview messagePreview: this.snapshot.current.messagePreview, }); @@ -276,6 +287,11 @@ export class RoomListItemViewModel const callType = call?.callType === CallType.Voice ? "voice" : call?.callType === CallType.Video ? "video" : undefined; + const canMoveToSection = SettingsStore.getValue("feature_room_list_sections"); + + // Build sections list for the "Move to section" submenu + const sections: Section[] = canMoveToSection ? RoomListItemViewModel.buildSections(roomTags) : []; + return { id: room.roomId, room, @@ -303,6 +319,8 @@ export class RoomListItemViewModel canMarkAsRead, canMarkAsUnread, roomNotifState, + canMoveToSection, + sections, }; } @@ -381,4 +399,53 @@ export class RoomListItemViewModel const echoChamber = EchoChamber.forRoom(this.props.room); echoChamber.notificationVolume = elementNotifState; }; + + public onCreateSection = async (): Promise => { + const newTag = await RoomListStoreV3.instance.createSection(); + // Add the room to the section + if (newTag) { + tagRoom(this.props.room, newTag); + } + }; + + public onToggleSection = (tag: string): void => { + tagRoom(this.props.room, tag); + }; + + private onOrderedCustomSectionsChange = (): void => { + // Rebuild sections list to reflect new order + const sections = RoomListItemViewModel.buildSections(this.props.room.tags); + this.snapshot.merge({ sections: keepIfSame(this.snapshot.current.sections, sections) }); + }; + + /** + * Build the list of available sections for the "Move to section" submenu. + * Order follows the canonical section order from RoomListStoreV3. + */ + private static buildSections(roomTags: Room["tags"]): Section[] { + const customSectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {}; + + return ( + RoomListStoreV3.instance.orderedSectionTags + // Exclude the Chats because the user toggle the other sections to move rooms in and out of the Chats section. + // Also exclude the default sections because they are available as toggles in the main context menu, and we don't want them to be duplicated in the "Move to section" submenu. + .filter( + (tag) => tag !== CHATS_TAG && tag !== DefaultTagID.Favourite && tag !== DefaultTagID.LowPriority, + ) + .map((tag) => ({ + tag, + name: RoomListItemViewModel.getSectionName(tag, customSectionData), + isSelected: Boolean(roomTags[tag]), + })) + ); + } + + /** + * Get the display name for a section based on its tag. + */ + private static getSectionName(tag: string, customSectionData: Record): string { + if (tag === DefaultTagID.Favourite) return _t("room_list|section|favourites"); + if (tag === DefaultTagID.LowPriority) return _t("room_list|section|low_priority"); + return customSectionData[tag]?.name || tag; + } } diff --git a/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts index a861c0894a..a7b8d6dd2d 100644 --- a/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts @@ -15,6 +15,9 @@ import { import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; import { NotificationStateEvents } from "../../stores/notifications/NotificationState"; import { type RoomNotificationState } from "../../stores/notifications/RoomNotificationState"; +import SettingsStore from "../../settings/SettingsStore"; +import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag"; +import RoomListStoreV3, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3"; interface RoomListSectionHeaderViewModelProps { tag: string; @@ -42,7 +45,19 @@ export class RoomListSectionHeaderViewModel private readonly expandedBySpace = new Map(); public constructor(props: RoomListSectionHeaderViewModelProps) { - super(props, { id: props.tag, title: props.title, isExpanded: true, isUnread: false }); + const isDefaultSection = + props.tag === DefaultTagID.Favourite || props.tag === DefaultTagID.LowPriority || props.tag === CHATS_TAG; + super(props, { + id: props.tag, + title: props.title, + isExpanded: true, + isUnread: false, + displaySectionMenu: !isDefaultSection, + }); + const sectionWatherRef = SettingsStore.watchSetting("RoomList.CustomSectionData", null, () => + this.onCustomSectionDataChange(), + ); + this.disposables.track(() => SettingsStore.unwatchSetting(sectionWatherRef)); } public onClick = (): void => { @@ -120,4 +135,25 @@ export class RoomListSectionHeaderViewModel this.roomNotificationStates.clear(); super.dispose(); } + + /** + * Handle changes to custom section data. + */ + private onCustomSectionDataChange(): void { + const customSectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {}; + const sectionData = customSectionData[this.props.tag]; + if (sectionData) { + this.snapshot.merge({ title: sectionData.name }); + } + } + + public editSection = async (): Promise => { + await RoomListStoreV3.instance.editSection(this.props.tag); + }; + + public removeSection = async (): Promise => { + // There is one notification state per room in the section + const isEmpty = this.roomNotificationStates.size === 0; + await RoomListStoreV3.instance.removeSection(this.props.tag, isEmpty); + }; } diff --git a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts index d2ff465db9..e711e340b0 100644 --- a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts @@ -13,6 +13,7 @@ import { type RoomListViewState, type RoomListSection, _t, + type ToastType, } from "@element-hq/web-shared-components"; import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; @@ -91,6 +92,11 @@ export class RoomListViewModel // Don't clear section vm because we want to keep the expand/collapse state even during space changes. private readonly roomSectionHeaderViewModels = new Map(); + /** + * Reference to the currently displayed toast, used to automatically close the toast after a timeout. + */ + private toastRef?: number; + public constructor(props: RoomListViewModelProps) { const activeSpace = SpaceStore.instance.activeSpaceRoom; @@ -144,6 +150,20 @@ export class RoomListViewModel this.onListsLoaded, ); + // Subscribe to section creation + this.disposables.trackListener( + RoomListStoreV3.instance, + RoomListStoreV3Event.SectionCreated as any, + this.onSectionCreated as (...args: unknown[]) => void, + ); + + // Subscribe to room tagging + this.disposables.trackListener( + RoomListStoreV3.instance, + RoomListStoreV3Event.RoomTagged as any, + this.onRoomTagged, + ); + // Subscribe to active room changes to update selected room const dispatcherRef = dispatcher.register(this.onDispatch); this.disposables.track(() => { @@ -264,7 +284,8 @@ export class RoomListViewModel public getSectionHeaderViewModel(tag: string): RoomListSectionHeaderViewModel { if (this.roomSectionHeaderViewModels.has(tag)) return this.roomSectionHeaderViewModels.get(tag)!; - const title = TAG_TO_TITLE_MAP[tag] || tag; + const customSections = SettingsStore.getValue("RoomList.CustomSectionData"); + const title = TAG_TO_TITLE_MAP[tag] || customSections[tag]?.name || tag; const viewModel = new RoomListSectionHeaderViewModel({ tag, title, @@ -487,6 +508,7 @@ export class RoomListViewModel private async updateRoomListData( isRoomChange: boolean = false, roomIdOverride: string | null = null, + scrollToSectionTag: string | undefined = undefined, ): Promise { // Determine the room ID to use for calculations // Use override if provided (e.g., during space changes), otherwise fall back to RoomViewStore @@ -531,17 +553,23 @@ export class RoomListViewModel // Update filter keys - only update if they have actually changed to prevent unnecessary re-renders of the room list const previousFilterKeys = this.snapshot.current.roomListState.filterKeys; const newFilterKeys = this.roomsResult.filterKeys?.map((k) => String(k)); + const viewSections = toRoomListSection(this.sections); + + const resolvedScrollToSectionTag = + scrollToSectionTag && viewSections.some((s) => s.id === scrollToSectionTag) + ? scrollToSectionTag + : undefined; + const roomListState: RoomListViewState = { activeRoomIndex, spaceId: this.roomsResult.spaceId, filterKeys: keepIfSame(previousFilterKeys, newFilterKeys), + scrollToSectionTag: resolvedScrollToSectionTag, }; const activeFilterId = this.activeFilter !== undefined ? filterKeyToIdMap.get(this.activeFilter) : undefined; const isRoomListEmpty = this.roomsResult.sections.every((section) => section.rooms.length === 0); const isLoadingRooms = RoomListStoreV3.instance.isLoadingRooms; - - const viewSections = toRoomListSection(this.sections); const previousSections = this.snapshot.current.sections; // Single atomic snapshot update @@ -572,6 +600,31 @@ export class RoomListViewModel }); } }; + + public onSectionCreated = (tag: string): void => { + this.updateRoomListData(false, null, tag); + this.showToast("section_created"); + }; + + public onRoomTagged = (): void => { + this.showToast("chat_moved"); + }; + + public closeToast: () => void = () => { + clearTimeout(this.toastRef); + this.snapshot.merge({ + toast: undefined, + }); + }; + + private showToast(toast: ToastType): void { + clearTimeout(this.toastRef); + this.snapshot.merge({ toast }); + // Automatically close the toast after 15 seconds + this.toastRef = setTimeout(() => { + this.closeToast(); + }, 15 * 1000); + } } /** @@ -584,9 +637,11 @@ function computeSections( roomsResult: RoomsResult, isSectionExpanded: (tag: string) => boolean, ): { sections: Section[]; isFlatList: boolean } { + const customSections = SettingsStore.getValue("RoomList.CustomSectionData"); + const sections = roomsResult.sections - // Only include sections that have rooms - .filter((section) => section.rooms.length > 0) + // Only include sections that have rooms or are custom sections (which may be empty but should still be shown) + .filter((section) => section.rooms.length > 0 || customSections[section.tag]) // Remove roomIds for sections that are currently collapsed according to their section header view model .map((section) => ({ ...section, diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/body/TextualBodyViewModel.tsx b/apps/web/src/viewmodels/room/timeline/event-tile/body/TextualBodyViewModel.tsx new file mode 100644 index 0000000000..e6ab537f33 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/body/TextualBodyViewModel.tsx @@ -0,0 +1,330 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type MouseEvent } from "react"; +import { MsgType, type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { + BaseViewModel, + LINKIFIED_DATA_ATTRIBUTE, + TextualBodyViewBodyWrapperKind, + TextualBodyViewKind, + type TextualBodyViewModel as TextualBodyViewModelInterface, + type TextualBodyViewSnapshot, +} from "@element-hq/web-shared-components"; + +import { formatDate } from "../../../../../DateUtils"; +import Modal from "../../../../../Modal"; +import dis from "../../../../../dispatcher/dispatcher"; +import { _t } from "../../../../../languageHandler"; +import { IntegrationManagers } from "../../../../../integrations/IntegrationManagers"; +import { tryTransformPermalinkToLocalHref } from "../../../../../utils/permalinks/Permalinks"; +import { Action } from "../../../../../dispatcher/actions"; +import QuestionDialog from "../../../../../components/views/dialogs/QuestionDialog"; +import MessageEditHistoryDialog from "../../../../../components/views/dialogs/MessageEditHistoryDialog"; +import { type TimelineRenderingType } from "../../../../../contexts/RoomContext"; + +const CAPTION_MESSAGE_TYPES = new Set([MsgType.Image, MsgType.File, MsgType.Audio, MsgType.Video]); + +export interface TextualBodyViewModelProps { + /** + * Optional DOM id forwarded to the root textual body element. + */ + id?: string; + /** + * Matrix event used to derive the body kind, sender label, starter link, edit state, and moderation state. + */ + mxEvent: MatrixEvent; + /** + * Optional URL that wraps the body when the message should behave as a highlighted link target. + */ + highlightLink?: string; + /** + * Event id of the replacement event, when this message has been edited. + */ + replacingEventId?: string; + /** + * Whether the user is viewing a hidden message while moderation is pending. + */ + isSeeingThroughMessageHiddenForModeration?: boolean; + /** + * Timeline context used when dispatching actions from textual body interactions. + */ + timelineRenderingType: TimelineRenderingType; +} + +export class TextualBodyViewModel + extends BaseViewModel + implements TextualBodyViewModelInterface +{ + private static readonly getKind = (mxEvent: MatrixEvent): TextualBodyViewKind => { + const msgtype = mxEvent.getContent().msgtype as MsgType | undefined; + + if (msgtype === MsgType.Notice) { + return TextualBodyViewKind.NOTICE; + } + + if (msgtype === MsgType.Emote) { + return TextualBodyViewKind.EMOTE; + } + + if (msgtype && CAPTION_MESSAGE_TYPES.has(msgtype)) { + return TextualBodyViewKind.CAPTION; + } + + return TextualBodyViewKind.TEXT; + }; + + private static readonly getStarterLink = (mxEvent: MatrixEvent): string | undefined => { + const starterLink = mxEvent.getContent().data?.["org.matrix.neb.starter_link"]; + + return typeof starterLink === "string" ? starterLink : undefined; + }; + + private static readonly computeBodyWrapperSnapshot = ( + props: TextualBodyViewModelProps, + ): Pick => { + if (props.highlightLink) { + return { + bodyWrapper: TextualBodyViewBodyWrapperKind.LINK, + bodyLinkHref: props.highlightLink, + bodyActionAriaLabel: undefined, + }; + } + + if (TextualBodyViewModel.getStarterLink(props.mxEvent)) { + return { + bodyWrapper: TextualBodyViewBodyWrapperKind.ACTION, + bodyLinkHref: undefined, + bodyActionAriaLabel: undefined, + }; + } + + return { + bodyWrapper: TextualBodyViewBodyWrapperKind.NONE, + bodyLinkHref: undefined, + bodyActionAriaLabel: undefined, + }; + }; + + private static readonly computeEditedMarkerSnapshot = ( + props: TextualBodyViewModelProps, + ): Pick< + TextualBodyViewSnapshot, + | "showEditedMarker" + | "editedMarkerText" + | "editedMarkerAriaLabel" + | "editedMarkerTooltip" + | "editedMarkerCaption" + > => { + if (!props.replacingEventId) { + return { + showEditedMarker: false, + editedMarkerText: undefined, + editedMarkerAriaLabel: undefined, + editedMarkerTooltip: undefined, + editedMarkerCaption: undefined, + }; + } + + const replacingDate = props.mxEvent.replacingEventDate(); + const date = replacingDate ? formatDate(replacingDate) : undefined; + + return { + showEditedMarker: true, + editedMarkerText: `(${_t("common|edited")})`, + editedMarkerAriaLabel: _t("timeline|edits|tooltip_label", { date }), + editedMarkerTooltip: _t("timeline|edits|tooltip_title", { date }), + editedMarkerCaption: _t("timeline|edits|tooltip_sub"), + }; + }; + + private static readonly computePendingModerationSnapshot = ( + props: TextualBodyViewModelProps, + ): Pick => { + if (!props.isSeeingThroughMessageHiddenForModeration) { + return { + showPendingModerationMarker: false, + pendingModerationText: undefined, + }; + } + + const visibility = props.mxEvent.messageVisibility(); + if (visibility.visible) { + throw new Error("TextualBodyViewModel should only render pending moderation for hidden messages"); + } + + const text = visibility.reason + ? _t("timeline|pending_moderation_reason", { reason: visibility.reason }) + : _t("timeline|pending_moderation"); + + return { + showPendingModerationMarker: true, + pendingModerationText: `(${text})`, + }; + }; + + private static readonly computeEventSnapshot = ( + props: TextualBodyViewModelProps, + ): Pick< + TextualBodyViewSnapshot, + | "kind" + | "bodyWrapper" + | "bodyLinkHref" + | "bodyActionAriaLabel" + | "showEditedMarker" + | "editedMarkerText" + | "editedMarkerTooltip" + | "editedMarkerCaption" + | "showPendingModerationMarker" + | "pendingModerationText" + | "emoteSenderName" + > => ({ + kind: TextualBodyViewModel.getKind(props.mxEvent), + emoteSenderName: props.mxEvent.sender?.name ?? props.mxEvent.getSender(), + ...TextualBodyViewModel.computeBodyWrapperSnapshot(props), + ...TextualBodyViewModel.computeEditedMarkerSnapshot(props), + ...TextualBodyViewModel.computePendingModerationSnapshot(props), + }); + + private static readonly computeSnapshot = (props: TextualBodyViewModelProps): TextualBodyViewSnapshot => ({ + id: props.id, + ...TextualBodyViewModel.computeEventSnapshot(props), + }); + + public constructor(props: TextualBodyViewModelProps) { + super(props, TextualBodyViewModel.computeSnapshot(props)); + } + + public setId(id: string | undefined): void { + this.props = { + ...this.props, + id, + }; + + this.snapshot.merge({ id }); + } + + public setEvent(mxEvent: MatrixEvent): void { + this.props = { + ...this.props, + mxEvent, + }; + + this.snapshot.merge(TextualBodyViewModel.computeEventSnapshot(this.props)); + } + + public setHighlightLink(highlightLink: string | undefined): void { + this.props = { + ...this.props, + highlightLink, + }; + + this.snapshot.merge(TextualBodyViewModel.computeBodyWrapperSnapshot(this.props)); + } + + public setReplacingEventId(replacingEventId: string | undefined): void { + this.props = { + ...this.props, + replacingEventId, + }; + + this.snapshot.merge(TextualBodyViewModel.computeEditedMarkerSnapshot(this.props)); + } + + public setIsSeeingThroughMessageHiddenForModeration( + isSeeingThroughMessageHiddenForModeration: boolean | undefined, + ): void { + this.props = { + ...this.props, + isSeeingThroughMessageHiddenForModeration, + }; + + this.snapshot.merge(TextualBodyViewModel.computePendingModerationSnapshot(this.props)); + } + + public setTimelineRenderingType(timelineRenderingType: TimelineRenderingType): void { + this.props = { + ...this.props, + timelineRenderingType, + }; + } + + public onRootClick = (event: MouseEvent): void => { + let target: HTMLLinkElement | null = event.target as HTMLLinkElement; + + if (target.dataset?.[LINKIFIED_DATA_ATTRIBUTE]) { + return; + } + + if (target.nodeName !== "A") { + target = target.closest("a"); + } + + if (!target) { + return; + } + + const localHref = tryTransformPermalinkToLocalHref(target.href); + if (localHref !== target.href) { + event.preventDefault(); + window.location.hash = localHref; + } + }; + + public onBodyActionClick = (event: MouseEvent): void => { + event.preventDefault(); + + const starterLink = TextualBodyViewModel.getStarterLink(this.props.mxEvent); + if (!starterLink) { + return; + } + + const managers = IntegrationManagers.sharedInstance(); + if (!managers.hasManager()) { + managers.openNoManagerDialog(); + return; + } + + const integrationManager = managers.getPrimaryManager(); + const scalarClient = integrationManager?.getScalarClient(); + scalarClient?.connect().then(() => { + const completeUrl = scalarClient.getStarterLink(starterLink); + const integrationsUrl = integrationManager!.uiUrl; + const { finished } = Modal.createDialog(QuestionDialog, { + title: _t("timeline|scalar_starter_link|dialog_title"), + description:
{_t("timeline|scalar_starter_link|dialog_description", { integrationsUrl })}
, + button: _t("action|continue"), + }); + + finished.then(([confirmed]) => { + if (!confirmed) { + return; + } + + const width = Math.min(window.screen.width, 1024); + const height = Math.min(window.screen.height, 800); + const left = (window.screen.width - width) / 2; + const top = (window.screen.height - height) / 2; + const features = `height=${height}, width=${width}, top=${top}, left=${left},`; + const wnd = window.open(completeUrl, "_blank", features)!; + wnd.opener = null; + }); + }); + }; + + public onEditedMarkerClick = (): void => { + Modal.createDialog(MessageEditHistoryDialog, { mxEvent: this.props.mxEvent }); + }; + + public onEmoteSenderClick = (): void => { + dis.dispatch({ + action: Action.ComposerInsert, + userId: this.props.mxEvent.getSender(), + timelineRenderingType: this.props.timelineRenderingType, + }); + }; +} diff --git a/apps/web/test/setupTests.ts b/apps/web/test/setupTests.ts index 7e68edf19d..f514c10482 100644 --- a/apps/web/test/setupTests.ts +++ b/apps/web/test/setupTests.ts @@ -66,3 +66,28 @@ if (env["GITHUB_ACTIONS"] !== undefined) { require("./setup/setupManualMocks"); // must be first require("./setup/setupLanguage"); require("./setup/setupConfig"); + +// Utility to check for React errors during the tests +// Fails tests on errors like the following: +// In HTML,
cannot be a descendant of

. +// In HTML,
cannot be a descendant of . +// In HTML, text nodes cannot be a child of

. +// This will cause a hydration error. +// You provided a `checked` prop to a form field without an `onChange` handler. +let errors: any[] = []; +beforeEach(() => { + errors = []; + const originalError = console.error; + jest.spyOn(console, "error").mockImplementation((...args) => { + if (/validateDOMNesting|Hydration failed|hydration error|prop to a form field without an/i.test(args[0])) { + errors.push(args[0]); + } + originalError.call(console, ...args); + }); +}); +afterEach(() => { + mocked(console.error).mockRestore?.(); + if (errors.length > 0) { + throw new Error("Test failed due to React hydration errors in the console."); + } +}); diff --git a/apps/web/test/unit-tests/DeviceListener-test.ts b/apps/web/test/unit-tests/DeviceListener-test.ts index 5b1e5dd166..f8777182cf 100644 --- a/apps/web/test/unit-tests/DeviceListener-test.ts +++ b/apps/web/test/unit-tests/DeviceListener-test.ts @@ -348,11 +348,11 @@ describe("DeviceListener", () => { expect(SetupEncryptionToast.showToast).not.toHaveBeenCalled(); }); - it("does not show any toasts when no rooms are encrypted", async () => { + it("shows toasts even when no rooms are encrypted", async () => { jest.spyOn(mockClient.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(false); await createAndStart(); - expect(SetupEncryptionToast.showToast).not.toHaveBeenCalled(); + expect(SetupEncryptionToast.showToast).toHaveBeenCalled(); }); it("shows verify session toast when account has cross signing", async () => { diff --git a/apps/web/test/unit-tests/PosthogTrackers-test.ts b/apps/web/test/unit-tests/PosthogTrackers-test.ts index c835ecacd0..a8904e4080 100644 --- a/apps/web/test/unit-tests/PosthogTrackers-test.ts +++ b/apps/web/test/unit-tests/PosthogTrackers-test.ts @@ -18,20 +18,10 @@ describe("PosthogTrackers", () => { const tracker = new PosthogTrackers(); tracker.trackUrlPreview("$123456", false, [ { - title: "A preview", - image: { - imageThumb: "abc", - imageFull: "abc", - }, - link: "a-link", - }, - ]); - tracker.trackUrlPreview("$123456", false, [ - { - title: "A second preview", - link: "a-link", + image: {}, }, ]); + tracker.trackUrlPreview("$123456", false, [{}]); // Ignores subsequent calls. expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith({ eventName: "UrlPreviewRendered", diff --git a/apps/web/test/unit-tests/SupportedBrowser-test.ts b/apps/web/test/unit-tests/SupportedBrowser-test.ts index 64e7e838ab..1f63dfdf07 100644 --- a/apps/web/test/unit-tests/SupportedBrowser-test.ts +++ b/apps/web/test/unit-tests/SupportedBrowser-test.ts @@ -67,15 +67,15 @@ describe("SupportedBrowser", () => { // Safari 26.0 on macOS "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15", // Latest Firefox on macOS Sonoma - "Mozilla/5.0 (Macintosh; Intel Mac OS X 15.7; rv:145.0) Gecko/20100101 Firefox/147.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 15.7; rv:150.0) Gecko/20100101 Firefox/150.0", // Latest Edge on Windows "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.3856.84", // Latest Edge on macOS "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.3856.84", // Latest Firefox on Windows - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0", // Latest Firefox on Linux - "Mozilla/5.0 (X11; Linux i686; rv:147.0) Gecko/20100101 Firefox/147.0", + "Mozilla/5.0 (X11; Linux i686; rv:150.0) Gecko/20100101 Firefox/150.0", // Latest Chrome on Windows "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", ])("should not warn for supported browsers", testUserAgentFactory()); diff --git a/apps/web/test/unit-tests/accessibility/RovingTabIndexAdapter-test.tsx b/apps/web/test/unit-tests/accessibility/RovingTabIndexAdapter-test.tsx new file mode 100644 index 0000000000..e6ed27628a --- /dev/null +++ b/apps/web/test/unit-tests/accessibility/RovingTabIndexAdapter-test.tsx @@ -0,0 +1,96 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { render } from "jest-matrix-react"; +import { RovingAction, type RovingTabIndexProviderProps } from "@element-hq/web-shared-components"; + +import * as KeyBindingsManagerModule from "../../../src/KeyBindingsManager"; +import { KeyBindingAction } from "../../../src/accessibility/KeyboardShortcuts"; +import { RovingTabIndexProvider } from "../../../src/accessibility/RovingTabIndex"; + +jest.mock("@element-hq/web-shared-components", () => { + const actual = jest.requireActual("@element-hq/web-shared-components"); + const mockSharedRovingTabIndexProvider = jest.fn(({ children }: RovingTabIndexProviderProps) => { + return <>{children({ onDragEndHandler: jest.fn(), onKeyDownHandler: jest.fn() })}; + }); + + return { + __mockSharedRovingTabIndexProvider: mockSharedRovingTabIndexProvider, + ...actual, + RovingTabIndexProvider: mockSharedRovingTabIndexProvider, + }; +}); + +const getMockSharedRovingTabIndexProvider = (): jest.Mock => { + return jest.requireMock("@element-hq/web-shared-components").__mockSharedRovingTabIndexProvider as jest.Mock; +}; + +const getInjectedGetAction = (): NonNullable => { + const mockSharedRovingTabIndexProvider = getMockSharedRovingTabIndexProvider(); + expect(mockSharedRovingTabIndexProvider).toHaveBeenCalled(); + const getAction = (mockSharedRovingTabIndexProvider.mock.calls.at(-1)![0] as RovingTabIndexProviderProps).getAction; + expect(getAction).toBeDefined(); + return getAction!; +}; + +describe("RovingTabIndex adapter", () => { + beforeEach(() => { + const mockSharedRovingTabIndexProvider = getMockSharedRovingTabIndexProvider(); + mockSharedRovingTabIndexProvider.mockClear(); + jest.restoreAllMocks(); + }); + + it.each([ + [KeyBindingAction.ArrowDown, RovingAction.ArrowDown], + [KeyBindingAction.ArrowUp, RovingAction.ArrowUp], + [KeyBindingAction.ArrowRight, RovingAction.ArrowRight], + [KeyBindingAction.ArrowLeft, RovingAction.ArrowLeft], + [KeyBindingAction.Home, RovingAction.Home], + [KeyBindingAction.End, RovingAction.End], + [KeyBindingAction.Tab, RovingAction.Tab], + ])("maps %s to %s", (accessibilityAction, expectedRovingAction) => { + const manager = new KeyBindingsManagerModule.KeyBindingsManager(); + jest.spyOn(KeyBindingsManagerModule, "getKeyBindingsManager").mockReturnValue(manager); + jest.spyOn(manager, "getAccessibilityAction").mockReturnValue(accessibilityAction); + + render({() => null}); + + const getAction = getInjectedGetAction(); + expect(getAction({ key: "irrelevant" } as React.KeyboardEvent)).toBe(expectedRovingAction); + }); + + it("returns undefined when there is no matching accessibility action", () => { + const manager = new KeyBindingsManagerModule.KeyBindingsManager(); + jest.spyOn(KeyBindingsManagerModule, "getKeyBindingsManager").mockReturnValue(manager); + jest.spyOn(manager, "getAccessibilityAction").mockReturnValue(undefined); + + render({() => null}); + + const getAction = getInjectedGetAction(); + expect(getAction({ key: "x" } as React.KeyboardEvent)).toBeUndefined(); + }); + + it("forwards provider props to shared-components", () => { + const onKeyDown = jest.fn(); + + render( + + {() => null} + , + ); + + const mockSharedRovingTabIndexProvider = getMockSharedRovingTabIndexProvider(); + const props = mockSharedRovingTabIndexProvider.mock.calls.at(-1)![0] as RovingTabIndexProviderProps; + expect(props.handleHomeEnd).toBe(true); + expect(props.handleLoop).toBe(true); + expect(props.handleUpDown).toBe(true); + expect(props.onKeyDown).toBe(onKeyDown); + expect(props.scrollIntoView).toBe(true); + expect(props.getAction).toEqual(expect.any(Function)); + }); +}); diff --git a/apps/web/test/unit-tests/async-components/structures/__snapshots__/ErrorView-test.tsx.snap b/apps/web/test/unit-tests/async-components/structures/__snapshots__/ErrorView-test.tsx.snap index fe327c357a..8fc4a6dc59 100644 --- a/apps/web/test/unit-tests/async-components/structures/__snapshots__/ErrorView-test.tsx.snap +++ b/apps/web/test/unit-tests/async-components/structures/__snapshots__/ErrorView-test.tsx.snap @@ -102,9 +102,9 @@ exports[` should match snapshot 1`] = ` style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;" > @@ -748,7 +766,7 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t @@ -2960,7 +2996,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo tabindex="0" >
0
@@ -3023,7 +3059,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo tabindex="0" >
-
- -
  • -
    -
    - - N - -
    -
    - - Nested room - -
    -
    - 3 members -
    -
    -
    -
    - Join -
    - -
    - +
    + + Nested room + +
    +
    + 3 members +
    +
    +
    +
    + Join +
    + +
    -
    - +
    + +
    +
    +
    -
    -
    -
    - -
    -
    -
    + + +
  • + + +
  • renders 1`] = ` class="mx_SpaceHierarchy_roomTile_avatar" > should not render cycles 1`] = ` class="mx_SpaceHierarchy_roomTile_avatar" > should not render cycles 1`] = ` class="mx_SpaceHierarchy_roomTile_avatar" > should not render cycles 1`] = ` class="mx_SpaceHierarchy_roomTile_avatar" > should not render cycles 1`] = ` -
    diff --git a/apps/web/test/unit-tests/components/structures/auth/__snapshots__/CompleteSecurity-test.tsx.snap b/apps/web/test/unit-tests/components/structures/auth/__snapshots__/CompleteSecurity-test.tsx.snap index 422af8c795..636c0c6b9b 100644 --- a/apps/web/test/unit-tests/components/structures/auth/__snapshots__/CompleteSecurity-test.tsx.snap +++ b/apps/web/test/unit-tests/components/structures/auth/__snapshots__/CompleteSecurity-test.tsx.snap @@ -112,7 +112,7 @@ exports[`CompleteSecurity Allows verifying with another device if one is availab class="mx_EncryptionCard_buttons" >
    @@ -74,7 +74,7 @@ exports[` should render 1`] = ` class="mx_EncryptionCard_buttons" > +
    renders marker when beacon has location 1`] = ` class="mx_Marker_border" > renders own beacon status when user is live sharin class="mx_DialogOwnBeaconStatus" > renders sidebar correctly with beacons 1`] = ` class="mx_BeaconListItem" > renders sidebar correctly with beacons 1`] = ` /> -
    -
    renders sidebar correctly with beacons 1`] = ` d="M8 10a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2zm2 0v9h9v-9z" /> -
    -
    + +
    renders share buttons when there is a location /> -
    -
    renders share buttons when there is a location d="M8 10a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2zm2 0v9h9v-9z" /> -
    -
    + +
    `; diff --git a/apps/web/test/unit-tests/components/views/dialogs/CreateSectionDialog-test.tsx b/apps/web/test/unit-tests/components/views/dialogs/CreateSectionDialog-test.tsx new file mode 100644 index 0000000000..3e709d93bd --- /dev/null +++ b/apps/web/test/unit-tests/components/views/dialogs/CreateSectionDialog-test.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { render, screen } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; + +import { CreateSectionDialog } from "../../../../../src/components/views/dialogs/CreateSectionDialog"; + +describe("CreateSectionDialog", () => { + const onFinished: jest.Mock = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + function renderComponent(): void { + render(); + } + + it("renders the dialog", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("has the create section button disabled when the input is empty", () => { + renderComponent(); + const createButton = screen.getByRole("button", { name: "Create section" }); + expect(createButton).toBeDisabled(); + }); + + it("calls onFinished with true and the section name when create section is clicked", async () => { + renderComponent(); + const input = screen.getByRole("textbox"); + await userEvent.type(input, "My section"); + const createButton = screen.getByRole("button", { name: "Create section" }); + await userEvent.click(createButton); + expect(onFinished).toHaveBeenCalledWith(true, "My section"); + }); + + it("calls onFinished with false when the dialog is cancelled", async () => { + renderComponent(); + const cancelButton = screen.getByRole("button", { name: "Cancel" }); + await userEvent.click(cancelButton); + expect(onFinished).toHaveBeenCalledWith(false, ""); + }); + + it("calls onFinished with true and the section name when the form is submitted", async () => { + renderComponent(); + const input = screen.getByRole("textbox"); + await userEvent.type(input, "My section"); + await userEvent.keyboard("{Enter}"); + expect(onFinished).toHaveBeenCalledWith(true, "My section"); + }); + + describe("editing mode", () => { + function renderEditComponent(): void { + render(); + } + + it("pre-fills the input with the existing section name", () => { + renderEditComponent(); + const input = screen.getByRole("textbox"); + expect(input).toHaveValue("Existing Section"); + }); + + it("shows the edit section button instead of create section", () => { + renderEditComponent(); + expect(screen.getByRole("button", { name: "Edit section" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Create section" })).not.toBeInTheDocument(); + }); + + it("calls onFinished with the updated name when edit section is clicked", async () => { + renderEditComponent(); + const input = screen.getByRole("textbox"); + await userEvent.clear(input); + await userEvent.type(input, "Updated Section"); + await userEvent.click(screen.getByRole("button", { name: "Edit section" })); + expect(onFinished).toHaveBeenCalledWith(true, "Updated Section"); + }); + + it("has the edit section button disabled when the input is empty", async () => { + renderEditComponent(); + const input = screen.getByRole("textbox"); + await userEvent.clear(input); + expect(screen.getByRole("button", { name: "Edit section" })).toBeDisabled(); + }); + }); +}); diff --git a/apps/web/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx b/apps/web/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx index 3f9eb6ac5c..4f3a80736c 100644 --- a/apps/web/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx +++ b/apps/web/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx @@ -7,12 +7,13 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { fireEvent, render, screen, findByText } from "jest-matrix-react"; +import { findByText, fireEvent, render, screen } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; -import { RoomType, type MatrixClient, MatrixError, Room } from "matrix-js-sdk/src/matrix"; +import { type MatrixClient, MatrixError, Room, RoomType } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { sleep } from "matrix-js-sdk/src/utils"; import { mocked, type Mocked } from "jest-mock"; +import { UserVerificationStatus } from "matrix-js-sdk/src/crypto-api"; import InviteDialog from "../../../../../src/components/views/dialogs/InviteDialog"; import { InviteKind } from "../../../../../src/components/views/dialogs/InviteDialogTypes"; @@ -103,6 +104,11 @@ describe("InviteDialog", () => { beforeEach(() => { mockClient = getMockClientWithEventEmitter({ + getCrypto: jest.fn().mockReturnValue({ + getUserVerificationStatus: jest + .fn() + .mockResolvedValue(new UserVerificationStatus(false, false, true, false)), + }), getDomain: jest.fn().mockReturnValue(serverDomain), getUserId: jest.fn().mockReturnValue(bobId), getSafeUserId: jest.fn().mockReturnValue(bobId), @@ -449,4 +455,44 @@ describe("InviteDialog", () => { await flushPromises(); expect(screen.queryByText("@localpart:server.tld")).not.toBeInTheDocument(); }); + + describe("when inviting a user whose cryptographic identity we do not know", () => { + beforeEach(() => { + mocked(mockClient.getCrypto()!.getUserVerificationStatus).mockImplementation(async (u) => { + return new UserVerificationStatus(false, false, false, false); + }); + }); + + describe.each([InviteKind.Invite, InviteKind.Dm])("with invitekind '%s'", (kind) => { + const goButtonName = kind == InviteKind.Invite ? "Invite" : "Go"; + + beforeEach(() => { + render( + , + ); + }); + + it("should show a warning when inviting by user id", async () => { + await enterIntoSearchField(aliceId); + await userEvent.click(screen.getByRole("button", { name: goButtonName })); + await screen.findByText("Confirm inviting them", { exact: false }); + + expect(mocked(mockClient.getCrypto()!.getUserVerificationStatus)).toHaveBeenCalledTimes(1); + expect(mocked(mockClient.getCrypto()!.getUserVerificationStatus)).toHaveBeenCalledWith(aliceId); + }); + + it("should show a warning when inviting by email address", async () => { + await enterIntoSearchField("aaa@bbb"); + await userEvent.click(screen.getByRole("button", { name: goButtonName })); + await screen.findByText("Confirm inviting them", { exact: false }); + + // We shouldn't call getUserVerificationStatus on an email address + expect(mocked(mockClient.getCrypto()!.getUserVerificationStatus)).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/apps/web/test/unit-tests/components/views/dialogs/RemoveSectionDialog-test.tsx b/apps/web/test/unit-tests/components/views/dialogs/RemoveSectionDialog-test.tsx new file mode 100644 index 0000000000..5bc664e989 --- /dev/null +++ b/apps/web/test/unit-tests/components/views/dialogs/RemoveSectionDialog-test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { render, screen } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; + +import { RemoveSectionDialog } from "../../../../../src/components/views/dialogs/RemoveSectionDialog"; + +describe("RemoveSectionDialog", () => { + const onFinished: jest.Mock = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("renders the dialog when section is not empty", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + expect( + screen.getByText("The chats in this section will still be available in your chats list."), + ).toBeInTheDocument(); + }); + + it("renders the dialog when section is empty", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + expect( + screen.queryByText("The chats in this section will still be available in your chats list."), + ).not.toBeInTheDocument(); + }); + + it("calls onFinished with true when remove section is clicked", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Remove section" })); + expect(onFinished).toHaveBeenCalledWith(true); + }); + + it("calls onFinished with false when the dialog is cancelled", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onFinished).toHaveBeenCalledWith(false); + }); +}); diff --git a/apps/web/test/unit-tests/components/views/dialogs/RoomSettingsDialog-test.tsx b/apps/web/test/unit-tests/components/views/dialogs/RoomSettingsDialog-test.tsx index 73654cfc5f..5bc6933d50 100644 --- a/apps/web/test/unit-tests/components/views/dialogs/RoomSettingsDialog-test.tsx +++ b/apps/web/test/unit-tests/components/views/dialogs/RoomSettingsDialog-test.tsx @@ -24,6 +24,7 @@ import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext import SettingsStore from "../../../../../src/settings/SettingsStore"; import { UIFeature } from "../../../../../src/settings/UIFeature"; import DMRoomMap from "../../../../../src/utils/DMRoomMap"; +import { SdkContextClass } from "../../../../../src/contexts/SDKContext"; describe("", () => { const userId = "@alice:server.org"; @@ -43,6 +44,8 @@ describe("", () => { const room2 = new Room("!room2:server.org", mockClient, userId); room2.name = "Another Room"; + let sdkContext: SdkContextClass; + jest.spyOn(SettingsStore, "getValue"); beforeEach(() => { @@ -54,6 +57,9 @@ describe("", () => { return null; }); + sdkContext = new SdkContextClass(); + sdkContext.client = mockClient; + jest.spyOn(SettingsStore, "getValue").mockReset().mockReturnValue(false); const dmRoomMap = { @@ -63,7 +69,7 @@ describe("", () => { }); const getComponent = (onFinished = jest.fn(), propRoomId = roomId) => - render(, { + render(, { wrapper: ({ children }) => ( {children} ), @@ -79,7 +85,7 @@ describe("", () => { expect(getByText(`Room Settings - ${room.name}`)).toBeInTheDocument(); - rerender(); + rerender(); expect(getByText(`Room Settings - ${room2.name}`)).toBeInTheDocument(); }); diff --git a/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/ConfirmKeyStorageOffDialog-test.tsx.snap b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/ConfirmKeyStorageOffDialog-test.tsx.snap index 17811d5488..a8e6ca6cce 100644 --- a/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/ConfirmKeyStorageOffDialog-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/ConfirmKeyStorageOffDialog-test.tsx.snap @@ -60,7 +60,7 @@ exports[`ConfirmKeyStorageOffDialog renders 1`] = ` class="mx_EncryptionCard_buttons" > + +
    + +
    + + + +
    + +
    +
    +`; diff --git a/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap index b165dc24b6..28fe2c81d2 100644 --- a/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap @@ -29,11 +29,11 @@ exports[`DevtoolsDialog renders the devtools dialog 1`] = ` > Toolbox -
    Room ID: !id -
    -
    -
    + +
    @@ -298,7 +298,7 @@ exports[`DevtoolsDialog renders the devtools dialog 1`] = ` class="_controls_17lij_8" > should list spaces which are not par >
    should match the snapshot 1`] = ` > + + +
    +
    + + + +
    +
    +
    +
    +`; + +exports[`RemoveSectionDialog renders the dialog when section is not empty 1`] = ` +
    +
    + +
    +
    +`; diff --git a/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/RoomSettingsDialog-test.tsx.snap b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/RoomSettingsDialog-test.tsx.snap index c57922ed30..9521e705b3 100644 --- a/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/RoomSettingsDialog-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/RoomSettingsDialog-test.tsx.snap @@ -192,7 +192,7 @@ exports[` poll history displays poll history when tab clic >
    +
  • -
    Device ID: SIGNED -
    should render a single device - signed by owner 1`] = ` d="M8 10a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2zm2 0v9h9v-9z" /> -
    -
    + +
  • Displayname: @@ -95,11 +95,11 @@ exports[` should render a single device - signed by owner 1`] = ` Device keys
    • -
      ed25519: an_ed25519_public_key -
      should render a single device - signed by owner 1`] = ` d="M8 10a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2zm2 0v9h9v-9z" /> -
      -
      + +
    • -
      curve25519: a_curve25519_public_key -
      should render a single device - signed by owner 1`] = ` d="M8 10a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2zm2 0v9h9v-9z" /> -
      -
      + +
  • @@ -171,11 +171,11 @@ exports[` should render a single device - unsigned 1`] = ` >
    • -
      User ID: @alice:example.com -
      should render a single device - unsigned 1`] = ` d="M8 10a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2zm2 0v9h9v-9z" /> -
      -
      + +
    • -
      Device ID: UNSIGNED -
      should render a single device - unsigned 1`] = ` d="M8 10a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2zm2 0v9h9v-9z" /> -
      -
      + +
    • Displayname: @@ -259,11 +259,11 @@ exports[` should render a single device - unsigned 1`] = ` Device keys
      • -
        ed25519: an_ed25519_public_key -
        should render a single device - unsigned 1`] = ` d="M8 10a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2zm2 0v9h9v-9z" /> -
        -
        + +
      • -
        curve25519: a_curve25519_public_key -
        should render a single device - unsigned 1`] = ` d="M8 10a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2zm2 0v9h9v-9z" /> -
        -
        + +
    • @@ -335,11 +335,11 @@ exports[` should render a single device - verified by cross-signing 1`] >
      • -
        User ID: @alice:example.com -
        should render a single device - verified by cross-signing 1`] d="M8 10a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2zm2 0v9h9v-9z" /> -
        -
        + +
      • -
        Device ID: VERIFIED -
        should render a single device - verified by cross-signing 1`] d="M8 10a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2zm2 0v9h9v-9z" /> -
        -
        + +
      • Displayname: @@ -425,11 +425,11 @@ exports[` should render a single device - verified by cross-signing 1`] Device keys
        • -
          ed25519: an_ed25519_public_key -
          should render a single device - verified by cross-signing 1`] d="M8 10a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2zm2 0v9h9v-9z" /> -
          -
          + +
        • -
          curve25519: a_curve25519_public_key -
          should render a single device - verified by cross-signing 1`] d="M8 10a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2zm2 0v9h9v-9z" /> -
          -
          + +
      • @@ -501,11 +501,11 @@ exports[` should render a single user 1`] = ` >
        • -
          User ID: @alice:example.com -
          should render a single user 1`] = ` d="M8 10a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2zm2 0v9h9v-9z" /> -
          -
          + +
        • Membership: join diff --git a/apps/web/test/unit-tests/components/views/dialogs/invite/UnknownIdentityUsersWarningDialog-test.tsx b/apps/web/test/unit-tests/components/views/dialogs/invite/UnknownIdentityUsersWarningDialog-test.tsx new file mode 100644 index 0000000000..f3b7c0476f --- /dev/null +++ b/apps/web/test/unit-tests/components/views/dialogs/invite/UnknownIdentityUsersWarningDialog-test.tsx @@ -0,0 +1,104 @@ +/* + Copyright 2026 Element Creations Ltd. + + SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + Please see LICENSE files in the repository root for full details. + */ + +import React, { type ComponentProps } from "react"; +import { render, type RenderResult } from "jest-matrix-react"; +import { getAllByRole, getAllByText, getByText } from "@testing-library/dom"; + +import UnknownIdentityUsersWarningDialog from "../../../../../../src/components/views/dialogs/invite/UnknownIdentityUsersWarningDialog.tsx"; +import { InviteKind } from "../../../../../../src/components/views/dialogs/InviteDialogTypes.ts"; +import { DirectoryMember, ThreepidMember } from "../../../../../../src/utils/direct-messages.ts"; +import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../../test-utils"; + +describe("UnknownIdentityUsersWarningDialog", () => { + beforeEach(() => { + getMockClientWithEventEmitter({ + ...mockClientMethodsUser(), + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should show entries for each user", () => { + const result = renderComponent({ + users: [ + new DirectoryMember({ user_id: "@alice:example.com" }), + new DirectoryMember({ + user_id: "@bob:example.net", + display_name: "Bob", + avatar_url: "mxc://example.com/abc", + }), + new ThreepidMember("charlie@example.com"), + ], + }); + + const list = result.getByTestId("userlist"); + const entries = getAllByRole(list, "option"); + expect(entries).toHaveLength(3); + + // No displayname so mxid is displayed twice + expect(getAllByText(entries[0], "@alice:example.com")).toHaveLength(2); + + getByText(entries[1], "Bob"); + getByText(entries[2], "charlie@example.com"); + }); + + describe("in DM mode", () => { + const kind = InviteKind.Dm; + + it("shows a 'Continue' button", () => { + const onContinue = jest.fn(); + const result = renderComponent({ kind, onContinue }); + const continueButton = result.getByRole("button", { name: "Continue" }); + continueButton.click(); + expect(onContinue).toHaveBeenCalled(); + }); + + it("shows a 'Cancel' button", () => { + const onCancel = jest.fn(); + const result = renderComponent({ kind, onCancel }); + const cancelButton = result.getByRole("button", { name: "Cancel" }); + cancelButton.click(); + expect(onCancel).toHaveBeenCalled(); + }); + }); + + describe("in Invite mode", () => { + const kind = InviteKind.Invite; + + it("shows an 'Invite' button", () => { + const onContinue = jest.fn(); + const result = renderComponent({ kind, onContinue }); + const continueButton = result.getByRole("button", { name: "Invite" }); + continueButton.click(); + expect(onContinue).toHaveBeenCalled(); + }); + + it("shows a 'Remove' button", () => { + const onRemove = jest.fn(); + const result = renderComponent({ kind, onRemove }); + const removeButton = result.getByRole("button", { name: "Remove" }); + removeButton.click(); + expect(onRemove).toHaveBeenCalled(); + }); + }); +}); + +function renderComponent(props: Partial>): RenderResult { + const props1: ComponentProps = { + onContinue: () => {}, + onCancel: () => {}, + onRemove: () => {}, + screenName: undefined, + kind: InviteKind.Dm, + users: [], + ...props, + }; + return render(); +} diff --git a/apps/web/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap b/apps/web/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap index bf82df4fff..a8ced5dbfc 100644 --- a/apps/web/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap @@ -106,7 +106,7 @@ exports[`AppTile for a pinned widget should render 1`] = `
          renders 'download' label if 'loca' is configured 1`] = `
          `; diff --git a/apps/web/test/unit-tests/components/views/elements/__snapshots__/Pill-test.tsx.snap b/apps/web/test/unit-tests/components/views/elements/__snapshots__/Pill-test.tsx.snap index a30a884772..35197567d8 100644 --- a/apps/web/test/unit-tests/components/views/elements/__snapshots__/Pill-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/elements/__snapshots__/Pill-test.tsx.snap @@ -40,7 +40,7 @@ exports[` should render the expected pill for @room 1`] = ` >
          renders 1`] = ` tabindex="0" >
          should render with the default label 1`] = ` class="_controls_17lij_8" > with live location disabled goes to labs flag scr
          -
          renders formatted m.text correctly linkification is not d="M8 10a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2zm2 0v9h9v-9z" /> -
          +
          @@ -100,7 +100,7 @@ exports[` renders formatted m.text correctly pills appear for an
          renders formatted m.text correctly pills appear for eve
          renders formatted m.text correctly pills appear for roo
          @@ -573,9 +573,9 @@ exports[` renders plain-text m.text correctly should not pillify exports[` renders plain-text m.text correctly should pillify a keyword responsible for triggering a notification 1`] = `"foo bar baz"`; -exports[` renders plain-text m.text correctly should pillify a permalink to a message in the same room with the label »Message from Member« 1`] = `"Visit
          Message from Member"`; +exports[` renders plain-text m.text correctly should pillify a permalink to a message in the same room with the label »Message from Member« 1`] = `"Visit Message from Member"`; -exports[` renders plain-text m.text correctly should pillify a permalink to an event in another room with the label »Message in Room 2« 1`] = `"Visit Message in Room 2"`; +exports[` renders plain-text m.text correctly should pillify a permalink to an event in another room with the label »Message in Room 2« 1`] = `"Visit Message in Room 2"`; exports[` renders plain-text m.text correctly should pillify a permalink to an unknown message in the same room with the label »Message« 1`] = `
          renders plain-text m.text correctly should pillify a pe exports[` renders plain-text m.text correctly should pillify a room alias permalink 1`] = `"Visit #room:example.com"`; -exports[` renders plain-text m.text correctly should pillify an MXID permalink 1`] = `"Chat with Member"`; +exports[` renders plain-text m.text correctly should pillify an MXID permalink 1`] = `"Chat with Member"`; exports[` renders plain-text m.text correctly simple message renders as expected 1`] = `
          renders a no polls message and a load more button when > should render empty state 1`] = ` tabindex="-1" >
          should show two pinned messages 1`] = ` >
          should show two pinned messages 1`] = `
          should show two pinned messages 1`] = ` class="mx_PinnedMessagesCard_unpin" >
          unpin all should not allow to unpinall 1`] = ` >
          unpin all should not allow to unpinall 1`] = `
          has button to edit topic 1`] = ` class="mx_RoomSummaryCard_container" > renders the room summary 1`] = ` class="mx_RoomSummaryCard_container" > renders the room topic in the summary 1`] = ` class="mx_RoomSummaryCard_container" > with crypto enabled renders 1`] = ` +

          with crypto enabled should render a deactivate button for +

          renders verify button 1`] = ` class="mx_UserInfo_container_verifyButton" > +

          renders custom user identifiers in the header 1` style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" > ({ @@ -723,25 +722,9 @@ describe("RoomHeader", () => { ], { addToState: true }, ); - let featureEnabled = true; - jest.spyOn(SettingsStore, "getValue").mockImplementation( - (flag) => flag === "feature_share_history_on_invite" && featureEnabled, - ); render(, getWrapper()); await waitFor(() => getByLabelText(document.body, "New members see history")); - - // Disable the labs flag and check the icon disappears - featureEnabled = false; - act(() => - defaultWatchManager.notifyUpdate( - "feature_share_history_on_invite", - null, - SettingLevel.DEVICE, - featureEnabled, - ), - ); - expect(queryByLabelText(document.body, "New members see history")).not.toBeInTheDocument(); }); it("shows a user icon if the room is encrypted and has world readable history", async () => { @@ -758,10 +741,6 @@ describe("RoomHeader", () => { ], { addToState: true }, ); - const featureEnabled = true; - jest.spyOn(SettingsStore, "getValue").mockImplementation( - (flag) => flag === "feature_share_history_on_invite" && featureEnabled, - ); render(, getWrapper()); await waitFor(() => getByLabelText(document.body, "Anyone can see history")); diff --git a/apps/web/test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/RoomHeader-test.tsx.snap b/apps/web/test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/RoomHeader-test.tsx.snap index 168a2f99e3..90f3fe474f 100644 --- a/apps/web/test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/RoomHeader-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/RoomHeader-test.tsx.snap @@ -9,7 +9,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
          should render pinned event 1`] = `
          should render pinned event with thread info 1`] = ` >
          should render pinned event with thread info 1`] = `
          should render the menu with all the options 1`] = ` aria-label="Open menu" aria-labelledby="radix-_r_14_" aria-orientation="vertical" - class="_menu_1w1u7_8" + class="_menu_1kl3y_8" data-align="start" data-orientation="vertical" data-radix-menu-content="" @@ -380,7 +380,7 @@ exports[` should render the menu without unpin and delete 1`] aria-label="Open menu" aria-labelledby="radix-_r_o_" aria-orientation="vertical" - class="_menu_1w1u7_8" + class="_menu_1kl3y_8" data-align="start" data-orientation="vertical" data-radix-menu-content="" diff --git a/apps/web/test/unit-tests/components/views/rooms/__snapshots__/PinnedMessageBanner-test.tsx.snap b/apps/web/test/unit-tests/components/views/rooms/__snapshots__/PinnedMessageBanner-test.tsx.snap index 45d3439fb3..7c810d6422 100644 --- a/apps/web/test/unit-tests/components/views/rooms/__snapshots__/PinnedMessageBanner-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/rooms/__snapshots__/PinnedMessageBanner-test.tsx.snap @@ -128,7 +128,7 @@ exports[` should display the last message when the pinned

          renders device and correct security card when class="mx_DeviceSecurityCard_description" > Verify your current session for enhanced secure messaging. - +

          renders device and correct security card when class="mx_DeviceSecurityCard_description" > Verify your current session for enhanced secure messaging. - +

          displays name edit form on rename button click id="device-rename-description-123" > Please be aware that session names are also visible to people you communicate with. - +
          renders a verified device 1`] = ` class="mx_DeviceSecurityCard_description" > This session is ready for secure messaging. - +

          @@ -175,13 +175,13 @@ exports[` renders device with metadata 1`] = ` class="mx_DeviceSecurityCard_description" > Verify or remove this session for best security and reliability. - +

          @@ -391,13 +391,13 @@ exports[` renders device without metadata 1`] = ` class="mx_DeviceSecurityCard_description" > Verify or remove this session for best security and reliability. - +

          diff --git a/apps/web/test/unit-tests/components/views/settings/devices/__snapshots__/DeviceVerificationStatusCard-test.tsx.snap b/apps/web/test/unit-tests/components/views/settings/devices/__snapshots__/DeviceVerificationStatusCard-test.tsx.snap index 0f7b56b998..953bf16969 100644 --- a/apps/web/test/unit-tests/components/views/settings/devices/__snapshots__/DeviceVerificationStatusCard-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/settings/devices/__snapshots__/DeviceVerificationStatusCard-test.tsx.snap @@ -34,13 +34,13 @@ exports[` renders a verified device 1`] = ` class="mx_DeviceSecurityCard_description" > This session is ready for secure messaging. - +

          @@ -79,13 +79,13 @@ exports[` renders an unverifiable device 1`] = ` class="mx_DeviceSecurityCard_description" > This session doesn't support encryption and thus can't be verified. - +

          @@ -124,13 +124,13 @@ exports[` renders an unverified device 1`] = ` class="mx_DeviceSecurityCard_description" > Verify or remove this session for best security and reliability. - +

          Consider removing old sessions (90 days or older) you don't use anymore. - +

          @@ -90,13 +90,13 @@ HTMLCollection [ > Verify your sessions for enhanced secure messaging or remove from those you don't recognize or use anymore. - +

          @@ -143,13 +143,13 @@ HTMLCollection [ > For best security, remove any session that you don't recognize or use anymore. - +

          diff --git a/apps/web/test/unit-tests/components/views/settings/devices/__snapshots__/LoginWithQRFlow-test.tsx.snap b/apps/web/test/unit-tests/components/views/settings/devices/__snapshots__/LoginWithQRFlow-test.tsx.snap index 0664d5d5b8..63eb09fca7 100644 --- a/apps/web/test/unit-tests/components/views/settings/devices/__snapshots__/LoginWithQRFlow-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/settings/devices/__snapshots__/LoginWithQRFlow-test.tsx.snap @@ -813,12 +813,12 @@ exports[` renders QR code 1`] = ` class="mx_LoginWithQR_qrWrapper" >
          QR Code
          @@ -875,11 +875,11 @@ exports[` renders check code confirmation 1`] = ` 2-digit code
          renders check code confirmation 1`] = ` /> diff --git a/apps/web/test/unit-tests/components/views/settings/tabs/room/__snapshots__/PeopleRoomSettingsTab-test.tsx.snap b/apps/web/test/unit-tests/components/views/settings/tabs/room/__snapshots__/PeopleRoomSettingsTab-test.tsx.snap index a3c6757bcb..af2fb7b1d7 100644 --- a/apps/web/test/unit-tests/components/views/settings/tabs/room/__snapshots__/PeopleRoomSettingsTab-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/settings/tabs/room/__snapshots__/PeopleRoomSettingsTab-test.tsx.snap @@ -17,7 +17,7 @@ exports[`PeopleRoomSettingsTab with requests to join renders requests fully 1`] > join rule warns when trying to make an encr To avoid these issues, create a - + for the conversation you plan to have. diff --git a/apps/web/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AppearanceUserSettingsTab-test.tsx.snap b/apps/web/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AppearanceUserSettingsTab-test.tsx.snap index 270b8a84aa..8521fdcc3c 100644 --- a/apps/web/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AppearanceUserSettingsTab-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AppearanceUserSettingsTab-test.tsx.snap @@ -229,7 +229,7 @@ exports[`AppearanceUserSettingsTab should render 1`] = ` class="mx_EventTile_avatar" >
          should display a verify button when the e

          , @@ -238,13 +238,13 @@ exports[` current session section renders current session s class="mx_DeviceSecurityCard_description" > Your current session is ready for secure messaging. - +

          @@ -411,13 +411,13 @@ exports[` current session section renders current session s class="mx_DeviceSecurityCard_description" > Verify your current session for enhanced secure messaging. - +

          looks as expected 1`] = ` > looks as expected 1`] = ` > should show all activated MetaSpaces in the correct orde class="mx_UserMenu_userAvatar" > for a public space renders addresses sec
          -
          -
          -

          +
          +
          - Address -

          -
          -
          + Published Addresses + +
          - - Published Addresses -
          -
          - Published addresses can be used by anyone on any server to join your space. To publish an address, it needs to be set as a local address first. -
          + Published addresses can be used by anyone on any server to join your space. To publish an address, it needs to be set as a local address first.
          +
          +
          -
          - - - - - -
          + not specified + + + + + + +
          +
          @@ -274,59 +274,65 @@ exports[` for a public space renders addresses sec
          - + + +
          +
          + No other published addresses yet, add one below +
          +
            +
            +
            +
          +
          +
          + + Local Addresses + +
          +
          + Set addresses for this space so users can find this space through your homeserver (matrix.org) +
          +
          +
          +
          + + Show more +
          - No other published addresses yet, add one below + This space has no local addresses
          -
            -
            -
            -
          -
          -
          - - Local Addresses - -
          -
          - Set addresses for this space so users can find this space through your homeserver (matrix.org) -
          -
          -
          -
          - - Show more - -
          -
          - This space has no local addresses -
          @@ -368,13 +374,13 @@ exports[` for a public space renders addresses sec > Add
          -
          -
          -
          -
          -
          + +
          + +
          +
          - +
          @@ -528,9 +534,6 @@ exports[` renders container 1`] = ` -
          diff --git a/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpaceTreeLevel-test.tsx.snap b/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpaceTreeLevel-test.tsx.snap index eba4a12ec2..0400231e07 100644 --- a/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpaceTreeLevel-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpaceTreeLevel-test.tsx.snap @@ -88,7 +88,7 @@ exports[`SpaceItem should render a space with subspaces 1`] = ` >

          Threads activity @@ -37,7 +37,7 @@ exports[`ThreadsActivityCentre renders notifications matching the snapshot 1`] = class="mx_DecoratedRoomAvatar _icon_bym9p_51" >

          Threads activity @@ -163,7 +163,7 @@ exports[`ThreadsActivityCentre should order the room with the same notification

          Threads activity @@ -196,7 +196,7 @@ exports[`ThreadsActivityCentre should order the room with the same notification class="mx_DecoratedRoomAvatar _icon_bym9p_51" >

          00:00
          Message #49

        • @user48:example.com
          00:00
          Message #48
        • @user47:example.com
          00:00
          Message #47
        • @user46:example.com
          00:00
          Message #46
        • @user45:example.com
          00:00
          Message #45
        • @user44:example.com
          00:00
          Message #44
        • @user43:example.com
          00:00
          Message #43
        • @user42:example.com
          00:00
          Message #42
        • @user41:example.com
          00:00
          Message #41
        • @user40:example.com
          00:00
          Message #40
        • @user39:example.com
          00:00
          Message #39
        • @user38:example.com
          00:00
          Message #38
        • @user37:example.com
          00:00
          Message #37
        • @user36:example.com
          00:00
          Message #36
        • @user35:example.com
          00:00
          Message #35
        • @user34:example.com
          00:00
          Message #34
        • @user33:example.com
          00:00
          Message #33
        • @user32:example.com
          00:00
          Message #32
        • @user31:example.com
          00:00
          Message #31
        • @user30:example.com
          00:00
          Message #30
        • @user29:example.com
          00:00
          Message #29
        • @user28:example.com
          00:00
          Message #28
        • @user27:example.com
          00:00
          Message #27
        • @user26:example.com
          00:00
          Message #26
        • @user25:example.com
          00:00
          Message #25
        • @user24:example.com
          00:00
          Message #24
        • @user23:example.com
          00:00
          Message #23
        • @user22:example.com
          00:00
          Message #22
        • @user21:example.com
          00:00
          Message #21
        • @user20:example.com
          00:00
          Message #20
        • @user19:example.com
          00:00
          Message #19
        • @user18:example.com
          00:00
          Message #18
        • @user17:example.com
          00:00
          Message #17
        • @user16:example.com
          00:00
          Message #16
        • @user15:example.com
          00:00
          Message #15
        • @user14:example.com
          00:00
          Message #14
        • @user13:example.com
          00:00
          Message #13
        • @user12:example.com
          00:00
          Message #12
        • @user11:example.com
          00:00
          Message #11
        • @user10:example.com
          00:00
          Message #10
        • @user9:example.com
          00:00
          Message #9
        • @user8:example.com
          00:00
          Message #8
        • @user7:example.com
          00:00
          Message #7
        • @user6:example.com
          00:00
          Message #6
        • @user5:example.com
          00:00
          Message #5
        • @user4:example.com
          00:00
          Message #4
        • @user3:example.com
          00:00
          Message #3
        • @user2:example.com
          00:00
          Message #2
        • @user1:example.com
          00:00
          Message #1
        • @user0:example.com
          00:00
          Message #0
        • +
        • @user49:example.com
          00:00
          Message #49
        • @user48:example.com
          00:00
          Message #48
        • @user47:example.com
          00:00
          Message #47
        • @user46:example.com
          00:00
          Message #46
        • @user45:example.com
          00:00
          Message #45
        • @user44:example.com
          00:00
          Message #44
        • @user43:example.com
          00:00
          Message #43
        • @user42:example.com
          00:00
          Message #42
        • @user41:example.com
          00:00
          Message #41
        • @user40:example.com
          00:00
          Message #40
        • @user39:example.com
          00:00
          Message #39
        • @user38:example.com
          00:00
          Message #38
        • @user37:example.com
          00:00
          Message #37
        • @user36:example.com
          00:00
          Message #36
        • @user35:example.com
          00:00
          Message #35
        • @user34:example.com
          00:00
          Message #34
        • @user33:example.com
          00:00
          Message #33
        • @user32:example.com
          00:00
          Message #32
        • @user31:example.com
          00:00
          Message #31
        • @user30:example.com
          00:00
          Message #30
        • @user29:example.com
          00:00
          Message #29
        • @user28:example.com
          00:00
          Message #28
        • @user27:example.com
          00:00
          Message #27
        • @user26:example.com
          00:00
          Message #26
        • @user25:example.com
          00:00
          Message #25
        • @user24:example.com
          00:00
          Message #24
        • @user23:example.com
          00:00
          Message #23
        • @user22:example.com
          00:00
          Message #22
        • @user21:example.com
          00:00
          Message #21
        • @user20:example.com
          00:00
          Message #20
        • @user19:example.com
          00:00
          Message #19
        • @user18:example.com
          00:00
          Message #18
        • @user17:example.com
          00:00
          Message #17
        • @user16:example.com
          00:00
          Message #16
        • @user15:example.com
          00:00
          Message #15
        • @user14:example.com
          00:00
          Message #14
        • @user13:example.com
          00:00
          Message #13
        • @user12:example.com
          00:00
          Message #12
        • @user11:example.com
          00:00
          Message #11
        • @user10:example.com
          00:00
          Message #10
        • @user9:example.com
          00:00
          Message #9
        • @user8:example.com
          00:00
          Message #8
        • @user7:example.com
          00:00
          Message #7
        • @user6:example.com
          00:00
          Message #6
        • @user5:example.com
          00:00
          Message #5
        • @user4:example.com
          00:00
          Message #4
        • @user3:example.com
          00:00
          Message #3
        • @user2:example.com
          00:00
          Message #2
        • @user1:example.com
          00:00
          Message #1
        • @user0:example.com
          00:00
          Message #0
        • diff --git a/apps/web/test/unit-tests/utils/form-test.ts b/apps/web/test/unit-tests/utils/form-test.ts new file mode 100644 index 0000000000..d1036e146f --- /dev/null +++ b/apps/web/test/unit-tests/utils/form-test.ts @@ -0,0 +1,18 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { onSubmitPreventDefault } from "../../../src/utils/form.ts"; + +describe("onSubmitPreventDefault", () => { + it("should preventDefault", () => { + const event = new SubmitEvent("submit"); + const spy = jest.spyOn(event, "preventDefault"); + + onSubmitPreventDefault(event); + expect(spy).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/test/unit-tests/utils/leave-behaviour-test.ts b/apps/web/test/unit-tests/utils/leave-behaviour-test.ts index 9da796a3a5..a6da3a4083 100644 --- a/apps/web/test/unit-tests/utils/leave-behaviour-test.ts +++ b/apps/web/test/unit-tests/utils/leave-behaviour-test.ts @@ -22,6 +22,9 @@ import SpaceStore from "../../../src/stores/spaces/SpaceStore"; import { MetaSpace } from "../../../src/stores/spaces"; import { type ActionPayload } from "../../../src/dispatcher/payloads"; import SettingsStore from "../../../src/settings/SettingsStore"; +import { CallStore } from "../../../src/stores/CallStore"; +import { type Call } from "../../../src/models/Call"; +import LegacyCallHandler from "../../../src/LegacyCallHandler"; describe("leaveRoomBehaviour", () => { SdkContextClass.instance.constructEagerStores(); // Initialize RoomViewStore @@ -76,6 +79,28 @@ describe("leaveRoomBehaviour", () => { defaultDispatcher.unregister(dispatcherRef); }; + it("hangs up legacy calls when leaving a room", async () => { + const hangupSpy = jest.spyOn(LegacyCallHandler.instance, "hangupOrReject").mockImplementation(() => {}); + + viewRoom(room); + await leaveRoomBehaviour(client, room.roomId); + + expect(hangupSpy).toHaveBeenCalledWith(room.roomId); + }); + + it("disconnects widget-based calls when leaving a room", async () => { + const mockCall = { + disconnect: jest.fn().mockResolvedValue(undefined), + } as unknown as Call; + + jest.spyOn(CallStore.instance, "getActiveCall").mockReturnValue(mockCall); + + viewRoom(room); + await leaveRoomBehaviour(client, room.roomId); + + expect(mockCall.disconnect).toHaveBeenCalled(); + }); + it("returns to the home page after leaving a room outside of a space that was being viewed", async () => { viewRoom(room); diff --git a/apps/web/test/unit-tests/utils/oidc/authorize-test.ts b/apps/web/test/unit-tests/utils/oidc/authorize-test.ts index c28e6325d7..b7bd2bfd9f 100644 --- a/apps/web/test/unit-tests/utils/oidc/authorize-test.ts +++ b/apps/web/test/unit-tests/utils/oidc/authorize-test.ts @@ -75,7 +75,7 @@ describe("OIDC authorization", () => { const authUrl = new URL(window.location.href); - expect(authUrl.searchParams.get("response_mode")).toEqual("fragment"); + expect(authUrl.searchParams.get("response_mode")).toEqual("query"); expect(authUrl.searchParams.get("response_type")).toEqual("code"); expect(authUrl.searchParams.get("client_id")).toEqual(clientId); expect(authUrl.searchParams.get("code_challenge_method")).toEqual("S256"); @@ -90,6 +90,18 @@ describe("OIDC authorization", () => { expect(authUrl.searchParams.has("nonce")).toBeTruthy(); expect(authUrl.searchParams.has("code_challenge")).toBeTruthy(); }); + + it("should prefer response_mode fragment if supported", async () => { + await startOidcLogin( + { ...delegatedAuthConfig, response_modes_supported: ["query", "fragment"] }, + clientId, + homeserverUrl, + ); + + const authUrl = new URL(window.location.href); + + expect(authUrl.searchParams.get("response_mode")).toEqual("fragment"); + }); }); describe("completeOidcLogin()", () => { @@ -131,19 +143,19 @@ describe("OIDC authorization", () => { }); it("should throw when query params do not include state and code", async () => { - await expect(async () => await completeOidcLogin({})).rejects.toThrow( + await expect(async () => await completeOidcLogin({}, "query")).rejects.toThrow( OidcClientError.InvalidQueryParameters, ); }); it("should make request complete authorization code grant", async () => { - await completeOidcLogin(params); + await completeOidcLogin(params, "fragment"); expect(completeAuthorizationCodeGrant).toHaveBeenCalledWith(code, state, "fragment"); }); it("should return accessToken, configured homeserver and identityServer", async () => { - const result = await completeOidcLogin(params); + const result = await completeOidcLogin(params, "query"); expect(result).toEqual({ accessToken: tokenResponse.access_token, diff --git a/apps/web/test/unit-tests/utils/room/tagRoom-test.ts b/apps/web/test/unit-tests/utils/room/tagRoom-test.ts index 20e5931a87..cec9b805a2 100644 --- a/apps/web/test/unit-tests/utils/room/tagRoom-test.ts +++ b/apps/web/test/unit-tests/utils/room/tagRoom-test.ts @@ -11,6 +11,7 @@ import { Room } from "matrix-js-sdk/src/matrix"; import RoomListActions from "../../../../src/actions/RoomListActions"; import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; import { DefaultTagID, type TagID } from "../../../../src/stores/room-list-v3/skip-list/tag"; +import { CUSTOM_SECTION_TAG_PREFIX } from "../../../../src/stores/room-list-v3/section"; import { tagRoom } from "../../../../src/utils/room/tagRoom"; import { getMockClientWithEventEmitter } from "../../../test-utils"; import * as getTagsForRoomUtils from "../../../../src/utils/room/getTagsForRoom"; @@ -18,6 +19,7 @@ import * as getTagsForRoomUtils from "../../../../src/utils/room/getTagsForRoom" describe("tagRoom()", () => { const userId = "@alice:server.org"; const roomId = "!room:server.org"; + const customTag = `${CUSTOM_SECTION_TAG_PREFIX}my-section`; const makeRoom = (tags: TagID[] = []): Room => { const client = getMockClientWithEventEmitter({ @@ -59,7 +61,7 @@ describe("tagRoom()", () => { expect(RoomListActions.tagRoom).toHaveBeenCalledWith( room.client, room, - DefaultTagID.LowPriority, // remove + null, // remove DefaultTagID.Favourite, // add ); }); @@ -73,10 +75,24 @@ describe("tagRoom()", () => { expect(RoomListActions.tagRoom).toHaveBeenCalledWith( room.client, room, - DefaultTagID.Favourite, // remove + null, // remove DefaultTagID.LowPriority, // add ); }); + + it("should tag a room with a custom section", () => { + const room = makeRoom(); + + tagRoom(room, customTag); + + expect(defaultDispatcher.dispatch).toHaveBeenCalled(); + expect(RoomListActions.tagRoom).toHaveBeenCalledWith( + room.client, + room, + null, // remove + customTag, // add + ); + }); }); describe("when a room is tagged as favourite", () => { @@ -137,4 +153,26 @@ describe("tagRoom()", () => { ); }); }); + + describe("when a room is tagged with a custom section", () => { + const otherCustomTag = `${CUSTOM_SECTION_TAG_PREFIX}other-section`; + + it.each([ + { label: "untag the custom section", applyTag: customTag, expectedAdd: null }, + { label: "replace with favourite", applyTag: DefaultTagID.Favourite, expectedAdd: DefaultTagID.Favourite }, + { label: "replace with another custom section", applyTag: otherCustomTag, expectedAdd: otherCustomTag }, + ])("should $label", ({ applyTag, expectedAdd }) => { + const room = makeRoom([customTag]); + + tagRoom(room, applyTag); + + expect(defaultDispatcher.dispatch).toHaveBeenCalled(); + expect(RoomListActions.tagRoom).toHaveBeenCalledWith( + room.client, + room, + customTag, // remove + expectedAdd, // add + ); + }); + }); }); diff --git a/apps/web/test/unit-tests/vector/__snapshots__/init-test.ts.snap b/apps/web/test/unit-tests/vector/__snapshots__/init-test.ts.snap index c774f572d5..7c5f97eb53 100644 --- a/apps/web/test/unit-tests/vector/__snapshots__/init-test.ts.snap +++ b/apps/web/test/unit-tests/vector/__snapshots__/init-test.ts.snap @@ -106,9 +106,9 @@ exports[`showIncompatibleBrowser should match snapshot 1`] = ` style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;" > ; const button2 = ; const button3 = ; const button4 = ; -// mock offsetParent Object.defineProperty(HTMLElement.prototype, "offsetParent", { get() { return this.parentNode; @@ -48,7 +62,7 @@ Object.defineProperty(HTMLElement.prototype, "offsetParent", { }); describe("RovingTabIndex", () => { - it("RovingTabIndexProvider renders children as expected", () => { + it("renders children as expected", () => { const { container } = render( {() => ( @@ -62,88 +76,81 @@ describe("RovingTabIndex", () => { expect(container.innerHTML).toBe("
          Test
          "); }); - it("RovingTabIndexProvider works as expected with useRovingTabIndex", () => { + it("works as expected with useRovingTabIndex", () => { const { container, rerender } = render( {() => ( - + <> {button1} {button2} {button3} - + )} , ); - // should begin with 0th being active checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); - // focus on 2nd button and test it is the only active one act(() => container.querySelectorAll("button")[2].focus()); checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]); - // focus on 1st button and test it is the only active one act(() => container.querySelectorAll("button")[1].focus()); checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]); - // check that the active button does not change even on an explicit blur event act(() => container.querySelectorAll("button")[1].blur()); checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]); - // update the children, it should remain on the same button rerender( {() => ( - + <> {button1} {button4} {button2} {button3} - + )} , ); checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0, -1]); - // update the children, remove the active button, it should move to the next one rerender( {() => ( - + <> {button1} {button4} {button3} - + )} , ); checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]); }); - it("RovingTabIndexProvider provides a ref to the dom element", () => { + it("provides a ref to the dom element", () => { const nodeRef = React.createRef(); - const MyButton = (props: HTMLAttributes) => { + const MyButton = (props: HTMLAttributes): React.JSX.Element => { const [onFocus, isActive, ref] = useRovingTabIndex(nodeRef); return )} - + )}
          , ); - // should begin with 0th being active checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); - // focus on 2nd button and test it is the only active one act(() => container.querySelectorAll("button")[2].focus()); checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]); }); @@ -177,7 +182,7 @@ describe("RovingTabIndex", () => { nodes: [node1, node2], }, { - type: Type.SetFocus, + type: RovingStateActionType.SetFocus, payload: { node: node2, }, @@ -190,49 +195,49 @@ describe("RovingTabIndex", () => { }); it("Unregister works as expected", () => { - const button1 = createButtonElement("Button 1"); - const button2 = createButtonElement("Button 2"); - const button3 = createButtonElement("Button 3"); - const button4 = createButtonElement("Button 4"); + const unregisterButton1 = createButtonElement("Button 1"); + const unregisterButton2 = createButtonElement("Button 2"); + const unregisterButton3 = createButtonElement("Button 3"); + const unregisterButton4 = createButtonElement("Button 4"); let state: IState = { - nodes: [button1, button2, button3, button4], + nodes: [unregisterButton1, unregisterButton2, unregisterButton3, unregisterButton4], }; state = reducer(state, { - type: Type.Unregister, + type: RovingStateActionType.Unregister, payload: { - node: button2, + node: unregisterButton2, }, }); expect(state).toStrictEqual({ - nodes: [button1, button3, button4], + nodes: [unregisterButton1, unregisterButton3, unregisterButton4], }); state = reducer(state, { - type: Type.Unregister, + type: RovingStateActionType.Unregister, payload: { - node: button3, + node: unregisterButton3, }, }); expect(state).toStrictEqual({ - nodes: [button1, button4], + nodes: [unregisterButton1, unregisterButton4], }); state = reducer(state, { - type: Type.Unregister, + type: RovingStateActionType.Unregister, payload: { - node: button4, + node: unregisterButton4, }, }); expect(state).toStrictEqual({ - nodes: [button1], + nodes: [unregisterButton1], }); state = reducer(state, { - type: Type.Unregister, + type: RovingStateActionType.Unregister, payload: { - node: button1, + node: unregisterButton1, }, }); expect(state).toStrictEqual({ @@ -247,12 +252,12 @@ describe("RovingTabIndex", () => { const ref4 = React.createRef(); render( - + <> - , + , ); let state: IState = { @@ -260,7 +265,7 @@ describe("RovingTabIndex", () => { }; state = reducer(state, { - type: Type.Register, + type: RovingStateActionType.Register, payload: { node: ref1.current!, }, @@ -271,7 +276,7 @@ describe("RovingTabIndex", () => { }); state = reducer(state, { - type: Type.Register, + type: RovingStateActionType.Register, payload: { node: ref2.current!, }, @@ -282,7 +287,7 @@ describe("RovingTabIndex", () => { }); state = reducer(state, { - type: Type.Register, + type: RovingStateActionType.Register, payload: { node: ref3.current!, }, @@ -293,7 +298,7 @@ describe("RovingTabIndex", () => { }); state = reducer(state, { - type: Type.Register, + type: RovingStateActionType.Register, payload: { node: ref4.current!, }, @@ -303,9 +308,8 @@ describe("RovingTabIndex", () => { nodes: [ref1.current, ref2.current, ref3.current, ref4.current], }); - // test that the automatic focus switch works for unmounting state = reducer(state, { - type: Type.SetFocus, + type: RovingStateActionType.SetFocus, payload: { node: ref2.current!, }, @@ -316,7 +320,7 @@ describe("RovingTabIndex", () => { }); state = reducer(state, { - type: Type.Unregister, + type: RovingStateActionType.Unregister, payload: { node: ref2.current!, }, @@ -326,9 +330,8 @@ describe("RovingTabIndex", () => { nodes: [ref1.current, ref3.current, ref4.current], }); - // test that the insert into the middle works as expected state = reducer(state, { - type: Type.Register, + type: RovingStateActionType.Register, payload: { node: ref2.current!, }, @@ -338,15 +341,14 @@ describe("RovingTabIndex", () => { nodes: [ref1.current, ref2.current, ref3.current, ref4.current], }); - // test that insertion at the edges works state = reducer(state, { - type: Type.Unregister, + type: RovingStateActionType.Unregister, payload: { node: ref1.current!, }, }); state = reducer(state, { - type: Type.Unregister, + type: RovingStateActionType.Unregister, payload: { node: ref4.current!, }, @@ -357,14 +359,14 @@ describe("RovingTabIndex", () => { }); state = reducer(state, { - type: Type.Register, + type: RovingStateActionType.Register, payload: { node: ref1.current!, }, }); state = reducer(state, { - type: Type.Register, + type: RovingStateActionType.Register, payload: { node: ref4.current!, }, @@ -376,18 +378,15 @@ describe("RovingTabIndex", () => { }); }); - describe("handles arrow keys", () => { - it("should handle up/down arrow keys work when handleUpDown=true", async () => { - const { container } = render( - - {({ onKeyDownHandler }) => ( -
          - {button1} - {button2} - {button3} -
          - )} -
          , + describe("handles keyboard navigation", () => { + it("handles up/down arrow keys when handleUpDown=true", async () => { + const { container } = renderToolbar( + <> + {button1} + {button2} + {button3} + , + { handleUpDown: true }, ); act(() => container.querySelectorAll("button")[0].focus()); @@ -405,29 +404,160 @@ describe("RovingTabIndex", () => { await userEvent.keyboard("[ArrowUp]"); checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); - // Does not loop without await userEvent.keyboard("[ArrowUp]"); checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); }); - it("should call scrollIntoView if specified", async () => { - const { container } = render( - - {({ onKeyDownHandler }) => ( -
          - {button1} - {button2} - {button3} -
          - )} -
          , + it("handles left/right arrow keys when handleLeftRight=true", async () => { + const { container } = renderToolbar( + <> + {button1} + {button2} + {button3} + , + { handleLeftRight: true }, + ); + + act(() => container.querySelectorAll("button")[0].focus()); + await userEvent.keyboard("[ArrowRight]"); + checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]); + + await userEvent.keyboard("[ArrowLeft]"); + checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); + }); + + it("handles Home and End when handleHomeEnd=true", async () => { + const { container } = renderToolbar( + <> + {button1} + {button2} + {button3} + , + { handleHomeEnd: true }, + ); + + act(() => container.querySelectorAll("button")[1].focus()); + checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]); + + await userEvent.keyboard("[End]"); + checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]); + + await userEvent.keyboard("[Home]"); + checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); + }); + + it("loops when handleLoop=true", async () => { + const { container } = renderToolbar( + <> + {button1} + {button2} + {button3} + , + { handleUpDown: true, handleLoop: true }, + ); + + act(() => container.querySelectorAll("button")[2].focus()); + await userEvent.keyboard("[ArrowDown]"); + checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); + + await userEvent.keyboard("[ArrowUp]"); + checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]); + }); + + it("uses a custom getAction mapper", async () => { + const getAction = vi.fn((ev: React.KeyboardEvent): RovingAction | undefined => { + if (ev.key === "j") { + return RovingAction.ArrowDown; + } + + return undefined; + }); + + const { container } = renderToolbar( + <> + {button1} + {button2} + {button3} + , + { handleUpDown: true, getAction }, + ); + + act(() => container.querySelectorAll("button")[0].focus()); + await userEvent.keyboard("j"); + + expect(getAction).toHaveBeenCalled(); + checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]); + }); + + it("handles input fields when handleInputFields=true", () => { + const { container, getByRole } = renderToolbar( + <> + {button1} + + {button2} + , + { handleUpDown: true, handleInputFields: true }, + ); + + act(() => container.querySelectorAll("button")[0].focus()); + const input = getByRole("textbox", { name: "Search input" }); + + fireEvent.keyDown(input, { key: "ArrowDown" }); + checkTabIndexes(container.querySelectorAll("button"), [-1, 0]); + }); + + it("moves from an input field with Tab when handleInputFields=false", () => { + const { container, getByRole } = renderToolbar( + <> + {button1} + + {button2} + , + ); + + act(() => container.querySelectorAll("button")[0].focus()); + const input = getByRole("textbox", { name: "Search input" }); + act(() => (input as HTMLElement).focus()); + + fireEvent.keyDown(input, { key: "Tab" }); + checkTabIndexes(container.querySelectorAll("button"), [-1, 0]); + }); + + it("stops provider processing when onKeyDown prevents default", () => { + const onKeyDown = vi.fn((event: React.KeyboardEvent): void => { + event.preventDefault(); + }); + const { container } = renderToolbar( + <> + {button1} + {button2} + {button3} + , + { handleUpDown: true, onKeyDown }, + ); + + act(() => container.querySelectorAll("button")[0].focus()); + fireEvent.keyDown(container.querySelector('[role="toolbar"]')!, { key: "ArrowDown" }); + + expect(onKeyDown).toHaveBeenCalled(); + checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); + }); + + it("calls scrollIntoView if specified", async () => { + const { container } = renderToolbar( + <> + {button1} + {button2} + {button3} + , + { handleUpDown: true, scrollIntoView: true }, ); act(() => container.querySelectorAll("button")[0].focus()); checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); const button = container.querySelectorAll("button")[1]; - const mock = jest.spyOn(button, "scrollIntoView"); + const mock = vi.spyOn(button, "scrollIntoView"); await userEvent.keyboard("[ArrowDown]"); expect(mock).toHaveBeenCalled(); }); diff --git a/packages/shared-components/src/core/roving/RovingTabIndex.tsx b/packages/shared-components/src/core/roving/RovingTabIndex.tsx new file mode 100644 index 0000000000..42055714a3 --- /dev/null +++ b/packages/shared-components/src/core/roving/RovingTabIndex.tsx @@ -0,0 +1,611 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useReducer, + type Dispatch, + type KeyboardEvent, + type ReactNode, + type Reducer, + type RefCallback, + type RefObject, +} from "react"; + +/** + * Returns whether an element should keep native arrow-key behaviour instead of + * being intercepted by roving focus navigation. + * + * This excludes radio buttons and checkboxes, which commonly participate in + * directional navigation patterns. + * + * @param el - The element being evaluated for native input behaviour. + * @returns `true` when the element should keep its own arrow-key handling. + */ +export function checkInputableElement(el: HTMLElement): boolean { + return el.matches('input:not([type="radio"]):not([type="checkbox"]), textarea, select, [contenteditable=true]'); +} + +/** + * The current state of a roving tabindex group. + */ +export interface IState { + /** + * The element that currently owns the active tab stop. + */ + activeNode?: HTMLElement; + /** + * Registered elements in DOM order. + */ + nodes: HTMLElement[]; +} + +/** + * The value exposed by {@link RovingTabIndexContext}. + */ +export interface IContext { + state: IState; + dispatch: Dispatch; +} + +/** + * React context used by roving tabindex participants to register themselves and + * update the active item. + */ +export const RovingTabIndexContext = createContext({ + state: { + nodes: [], // list of nodes in DOM order + }, + dispatch: () => {}, +}); +RovingTabIndexContext.displayName = "RovingTabIndexContext"; + +/** + * Internal reducer action kinds used by the roving tabindex state machine. + */ +export enum RovingStateActionType { + Register = "REGISTER", + Unregister = "UNREGISTER", + SetFocus = "SET_FOCUS", + Update = "UPDATE", +} + +/** + * An action dispatched to the roving tabindex reducer for node registration and + * focus updates. + */ +export interface IAction { + /** + * The reducer action kind. + */ + type: Exclude; + /** + * Action payload carrying the target node. + */ + payload: { + /** + * The DOM node affected by the action. + */ + node: HTMLElement; + }; +} + +interface UpdateAction { + type: RovingStateActionType.Update; + payload?: never; +} + +type Action = IAction | UpdateAction; + +/** + * Normalized navigation intents understood by the shared roving provider. + */ +export enum RovingAction { + Home = "HOME", + End = "END", + ArrowLeft = "ARROW_LEFT", + ArrowUp = "ARROW_UP", + ArrowRight = "ARROW_RIGHT", + ArrowDown = "ARROW_DOWN", + Tab = "TAB", +} + +/** + * Props for {@link RovingTabIndexProvider}. + */ +export interface RovingTabIndexProviderProps { + /** + * Whether directional navigation should wrap from the last item to the first + * and vice versa. + */ + handleLoop?: boolean; + /** + * Whether `Home` and `End` should move focus to the first and last item. + */ + handleHomeEnd?: boolean; + /** + * Whether vertical arrow keys should move focus within the group. + */ + handleUpDown?: boolean; + /** + * Whether horizontal arrow keys should move focus within the group. + */ + handleLeftRight?: boolean; + /** + * Whether text inputs and similar controls should participate in roving + * keyboard handling instead of keeping their native arrow-key behaviour. + */ + handleInputFields?: boolean; + /** + * Whether newly focused items should be scrolled into view. + * + * Pass `true` to use the browser default, or a scroll options object to + * control alignment and behaviour. + */ + scrollIntoView?: boolean | ScrollIntoViewOptions; + /** + * Render prop receiving keyboard and drag-end handlers for the roving + * container. + */ + children( + this: void, + renderProps: { + /** + * Handles keyboard navigation for the roving container. + */ + onKeyDownHandler(this: void, ev: KeyboardEvent): void; + /** + * Re-sorts registered elements after DOM reordering, such as drag and + * drop. + */ + onDragEndHandler(this: void): void; + }, + ): ReactNode; + /** + * Optional callback invoked before the provider performs its own keyboard + * handling. + * + * Call `preventDefault()` on the event to suppress the built-in behaviour. + */ + onKeyDown?(this: void, ev: KeyboardEvent, state: IState, dispatch: Dispatch): void; + /** + * Optional action resolver used to map keyboard events to + * {@link RovingAction} values. + * + * When omitted, a default mapping based on `KeyboardEvent.key` is used. + */ + getAction?(this: void, ev: KeyboardEvent): RovingAction | undefined; +} + +const nodeSorter = (a: HTMLElement, b: HTMLElement): number => { + if (a === b) { + return 0; + } + + const position = a.compareDocumentPosition(b); + + if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) { + return -1; + } else if (position & Node.DOCUMENT_POSITION_PRECEDING || position & Node.DOCUMENT_POSITION_CONTAINS) { + return 1; + } else { + return 0; + } +}; + +const getReplacementActiveNode = (nodes: HTMLElement[], removedIndex: number): HTMLElement | undefined => { + if (removedIndex >= nodes.length) { + return findPreviousSiblingElement(nodes, nodes.length - 1); + } + + return findNextSiblingElement(nodes, removedIndex) || findPreviousSiblingElement(nodes, removedIndex); +}; + +const handleRemovedActiveNode = (state: IState, removedIndex: number): void => { + state.activeNode = getReplacementActiveNode(state.nodes, removedIndex); + + if (document.activeElement === document.body) { + // if the focus got reverted to the body then the user was likely focused on the unmounted element + setTimeout(() => state.activeNode?.focus(), 0); + } +}; + +/** + * Reducer that tracks registered nodes and the currently active roving tab + * stop. + */ +export const reducer: Reducer = (state: IState, action: Action) => { + switch (action.type) { + case RovingStateActionType.Register: { + // Our list of nodes was empty, set activeNode to this first item + state.activeNode ??= action.payload.node; + + if (state.nodes.includes(action.payload.node)) return state; + + // Sadly due to the potential of DOM elements swapping order we can't do anything fancy like a binary insert + state.nodes.push(action.payload.node); + state.nodes.sort(nodeSorter); + + return { ...state }; + } + + case RovingStateActionType.Unregister: { + const oldIndex = state.nodes.indexOf(action.payload.node); + + if (oldIndex === -1) { + return state; // already removed, this should not happen + } + + if (state.nodes.splice(oldIndex, 1)[0] === state.activeNode) { + handleRemovedActiveNode(state, oldIndex); + } + + return { ...state }; + } + + case RovingStateActionType.SetFocus: { + if (state.activeNode === action.payload.node) return state; + state.activeNode = action.payload.node; + return { ...state }; + } + + case RovingStateActionType.Update: { + state.nodes.sort(nodeSorter); + return { ...state }; + } + + default: + return state; + } +}; + +const findSiblingElementInRange = ( + nodes: HTMLElement[], + startIndex: number, + endIndex: number, + step: 1 | -1, +): HTMLElement | undefined => { + if (step === 1) { + for (let i = startIndex; i < endIndex; i += step) { + if (nodes[i]?.offsetParent !== null) { + return nodes[i]; + } + } + } else { + for (let i = startIndex; i > endIndex; i += step) { + if (nodes[i]?.offsetParent !== null) { + return nodes[i]; + } + } + } +}; + +/** + * Finds the next visible sibling element starting from a given index. + * + * @param nodes - Registered roving nodes in DOM order. + * @param startIndex - The index to begin searching from. + * @param loop - Whether to wrap around when no visible sibling is found. + * @returns The next visible sibling element, if one exists. + */ +export const findNextSiblingElement = ( + nodes: HTMLElement[], + startIndex: number, + loop = false, +): HTMLElement | undefined => { + const sibling = findSiblingElementInRange(nodes, startIndex, nodes.length, 1); + + if (sibling || !loop) { + return sibling; + } + + return findSiblingElementInRange(nodes.slice(0, startIndex), 0, startIndex, 1); +}; + +/** + * Finds the previous visible sibling element starting from a given index. + * + * @param nodes - Registered roving nodes in DOM order. + * @param startIndex - The index to begin searching from. + * @param loop - Whether to wrap around when no visible sibling is found. + * @returns The previous visible sibling element, if one exists. + */ +export const findPreviousSiblingElement = ( + nodes: HTMLElement[], + startIndex: number, + loop = false, +): HTMLElement | undefined => { + const sibling = findSiblingElementInRange(nodes, startIndex, -1, -1); + + if (sibling || !loop) { + return sibling; + } + + const loopNodes = nodes.slice(startIndex + 1); + return findSiblingElementInRange(loopNodes, loopNodes.length - 1, -1, -1); +}; + +const getDefaultAction = (ev: KeyboardEvent): RovingAction | undefined => { + switch (ev.key) { + case "Home": + return RovingAction.Home; + case "End": + return RovingAction.End; + case "ArrowLeft": + return RovingAction.ArrowLeft; + case "ArrowUp": + return RovingAction.ArrowUp; + case "ArrowRight": + return RovingAction.ArrowRight; + case "ArrowDown": + return RovingAction.ArrowDown; + case "Tab": + return RovingAction.Tab; + default: + return undefined; + } +}; + +interface NavigationResult { + handled: boolean; + focusNode?: HTMLElement; +} + +interface StandardNavigationConfig { + enabled: boolean; + getFocusNode(state: IState): HTMLElement | undefined; +} + +const getAdjacentFocusNode = ( + nodes: HTMLElement[], + activeNode: HTMLElement | undefined, + backwards: boolean, + loop = false, +): HTMLElement | undefined => { + if (nodes.length === 0 || !activeNode) { + return undefined; + } + + const currentIndex = nodes.indexOf(activeNode); + const nextIndex = currentIndex + (backwards ? -1 : 1); + + return backwards + ? findPreviousSiblingElement(nodes, nextIndex, loop) + : findNextSiblingElement(nodes, nextIndex, loop); +}; + +const getInputNavigationResult = ( + action: RovingAction | undefined, + nodes: HTMLElement[], + activeNode: HTMLElement | undefined, + shiftKey: boolean, +): NavigationResult => { + if (action !== RovingAction.Tab) { + return { handled: false }; + } + + return { + handled: true, + focusNode: getAdjacentFocusNode(nodes, activeNode, shiftKey), + }; +}; + +const buildStandardNavigationConfig = ( + state: IState, + handleHomeEnd: boolean, + handleUpDown: boolean, + handleLeftRight: boolean, + handleLoop: boolean, +): Record => ({ + [RovingAction.Home]: { + enabled: handleHomeEnd, + getFocusNode: (currentState) => findNextSiblingElement(currentState.nodes, 0), + }, + [RovingAction.End]: { + enabled: handleHomeEnd, + getFocusNode: (currentState) => findPreviousSiblingElement(currentState.nodes, currentState.nodes.length - 1), + }, + [RovingAction.ArrowDown]: { + enabled: handleUpDown, + getFocusNode: (currentState) => + getAdjacentFocusNode(currentState.nodes, currentState.activeNode, false, handleLoop), + }, + [RovingAction.ArrowRight]: { + enabled: handleLeftRight, + getFocusNode: (currentState) => + getAdjacentFocusNode(currentState.nodes, currentState.activeNode, false, handleLoop), + }, + [RovingAction.ArrowUp]: { + enabled: handleUpDown, + getFocusNode: (currentState) => + getAdjacentFocusNode(currentState.nodes, currentState.activeNode, true, handleLoop), + }, + [RovingAction.ArrowLeft]: { + enabled: handleLeftRight, + getFocusNode: (currentState) => + getAdjacentFocusNode(currentState.nodes, currentState.activeNode, true, handleLoop), + }, + [RovingAction.Tab]: { + enabled: false, + getFocusNode: () => undefined, + }, +}); + +const getStandardNavigationResult = ( + action: RovingAction | undefined, + state: IState, + handleHomeEnd: boolean, + handleUpDown: boolean, + handleLeftRight: boolean, + handleLoop: boolean, +): NavigationResult => { + if (!action) { + return { handled: false }; + } + + const config = buildStandardNavigationConfig(state, handleHomeEnd, handleUpDown, handleLeftRight, handleLoop)[ + action + ]; + + if (!config?.enabled) { + return { handled: false }; + } + + return { + handled: true, + focusNode: config.getFocusNode(state), + }; +}; + +/** + * Provides shared roving tabindex state and keyboard handling for a group of + * focusable descendants. + */ +export const RovingTabIndexProvider: React.FC = ({ + children, + handleHomeEnd, + handleUpDown, + handleLeftRight, + handleLoop, + handleInputFields, + scrollIntoView, + onKeyDown, + getAction = getDefaultAction, +}) => { + const [state, dispatch] = useReducer(reducer, { + nodes: [], + }); + + const context = useMemo(() => ({ state, dispatch }), [state]); + + const onKeyDownHandler = useCallback( + (ev: KeyboardEvent) => { + if (onKeyDown) { + onKeyDown(ev, context.state, context.dispatch); + if (ev.defaultPrevented) { + return; + } + } + + const action = getAction(ev); + // Don't interfere with input default keydown behaviour + // but allow people to move focus from it with Tab. + const isInputTarget = !handleInputFields && checkInputableElement(ev.target as HTMLElement); + const { handled, focusNode } = isInputTarget + ? getInputNavigationResult(action, context.state.nodes, context.state.activeNode, ev.shiftKey) + : getStandardNavigationResult( + action, + context.state, + handleHomeEnd ?? false, + handleUpDown ?? false, + handleLeftRight ?? false, + handleLoop ?? false, + ); + + if (handled) { + ev.preventDefault(); + ev.stopPropagation(); + } + + if (focusNode) { + focusNode.focus(); + // programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves + dispatch({ + type: RovingStateActionType.SetFocus, + payload: { + node: focusNode, + }, + }); + if (scrollIntoView) { + focusNode.scrollIntoView(scrollIntoView); + } + } + }, + [ + context, + getAction, + onKeyDown, + handleHomeEnd, + handleUpDown, + handleLeftRight, + handleLoop, + handleInputFields, + scrollIntoView, + ], + ); + + const onDragEndHandler = useCallback(() => { + dispatch({ + type: RovingStateActionType.Update, + }); + }, []); + + return ( + + {children({ onKeyDownHandler, onDragEndHandler })} + + ); +}; + +/** + * Registers a focusable element with the nearest + * {@link RovingTabIndexContext}. + * + * @param inputRef - Optional ref to reuse for the registered DOM node. + * @returns A tuple containing: + * `onFocus` to mark the item active, + * `isActive` to drive `tabIndex`, + * `ref` to register the DOM node, + * and `nodeRef` pointing at the registered node. + */ +export const useRovingTabIndex = ( + inputRef?: RefObject, +): [() => void, boolean, RefCallback, RefObject] => { + const context = useContext(RovingTabIndexContext); + + let nodeRef = useRef(null); + + if (inputRef) { + // if we are given a ref, use it instead of ours + nodeRef = inputRef; + } + + const ref = useCallback((node: T | null) => { + if (node) { + nodeRef.current = node; + context.dispatch({ + type: RovingStateActionType.Register, + payload: { node }, + }); + } else { + context.dispatch({ + type: RovingStateActionType.Unregister, + payload: { node: nodeRef.current! }, + }); + nodeRef.current = null; + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const onFocus = useCallback(() => { + if (!nodeRef.current) { + console.warn("useRovingTabIndex.onFocus called but the react ref does not point to any DOM element!"); + return; + } + context.dispatch({ + type: RovingStateActionType.SetFocus, + payload: { node: nodeRef.current }, + }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // eslint-disable-next-line react-compiler/react-compiler + const isActive = context.state.activeNode === nodeRef.current; + return [onFocus, isActive, ref, nodeRef]; +}; diff --git a/packages/shared-components/src/core/roving/RovingTabIndexWrapper.tsx b/packages/shared-components/src/core/roving/RovingTabIndexWrapper.tsx new file mode 100644 index 0000000000..a17dcb4be9 --- /dev/null +++ b/packages/shared-components/src/core/roving/RovingTabIndexWrapper.tsx @@ -0,0 +1,32 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type ReactElement, type RefCallback, type RefObject } from "react"; + +import type React from "react"; +import { useRovingTabIndex } from "./RovingTabIndex"; + +interface IProps { + inputRef?: RefObject; + children( + this: void, + renderProps: { + onFocus: () => void; + isActive: boolean; + ref: RefCallback; + }, + ): ReactElement; +} + +/** + * Render-prop wrapper around {@link useRovingTabIndex} for class components and + * other places where hooks cannot be called directly. + */ +export const RovingTabIndexWrapper: React.FC = ({ children, inputRef }) => { + const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); + return children({ onFocus, isActive, ref }); +}; diff --git a/packages/shared-components/src/core/roving/index.ts b/packages/shared-components/src/core/roving/index.ts new file mode 100644 index 0000000000..3694df73a4 --- /dev/null +++ b/packages/shared-components/src/core/roving/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { + checkInputableElement, + findNextSiblingElement, + RovingAction, + RovingStateActionType, + RovingTabIndexContext, + RovingTabIndexProvider, + useRovingTabIndex, +} from "./RovingTabIndex"; +export type { IAction, IContext, IState, RovingTabIndexProviderProps } from "./RovingTabIndex"; +export { RovingTabIndexWrapper } from "./RovingTabIndexWrapper"; diff --git a/packages/shared-components/src/core/utils/FormattingUtils.stories.tsx b/packages/shared-components/src/core/utils/FormattingUtils.stories.tsx index e85be08e29..5f32ccea50 100644 --- a/packages/shared-components/src/core/utils/FormattingUtils.stories.tsx +++ b/packages/shared-components/src/core/utils/FormattingUtils.stories.tsx @@ -13,7 +13,7 @@ import formatBytesDoc from "../../../typedoc/functions/formatBytes.md?raw"; import formatSecondsDoc from "../../../typedoc/functions/formatSeconds.md?raw"; const meta = { - title: "utils/FormattingUtils", + title: "Core/FormattingUtils", parameters: { docs: { page: () => ( diff --git a/packages/shared-components/src/core/utils/LinkedText/LinkedText.stories.tsx b/packages/shared-components/src/core/utils/LinkedText/LinkedText.stories.tsx index e5c1b5bc5b..07418981fa 100644 --- a/packages/shared-components/src/core/utils/LinkedText/LinkedText.stories.tsx +++ b/packages/shared-components/src/core/utils/LinkedText/LinkedText.stories.tsx @@ -13,7 +13,7 @@ import { LinkedText } from "./LinkedText"; import { LinkedTextContext } from "./LinkedTextContext"; const meta = { - title: "Utils/LinkedText", + title: "Core/LinkedText", component: LinkedText, decorators: [ (Story, { args }) => ( diff --git a/packages/shared-components/src/core/utils/humanize.stories.tsx b/packages/shared-components/src/core/utils/humanize.stories.tsx index 6b3c294ee3..a54215a3c2 100644 --- a/packages/shared-components/src/core/utils/humanize.stories.tsx +++ b/packages/shared-components/src/core/utils/humanize.stories.tsx @@ -12,7 +12,7 @@ import type { Meta } from "@storybook/react-vite"; import humanizeTimeDoc from "../../../typedoc/functions/humanizeTime.md?raw"; const meta = { - title: "utils/humanize", + title: "Core/Humanize", parameters: { docs: { page: () => ( diff --git a/packages/shared-components/src/core/utils/linkify.stories.tsx b/packages/shared-components/src/core/utils/linkify.stories.tsx index fbba0be228..354c548460 100644 --- a/packages/shared-components/src/core/utils/linkify.stories.tsx +++ b/packages/shared-components/src/core/utils/linkify.stories.tsx @@ -18,7 +18,7 @@ import generateLinkedTextOptions from "../../../typedoc/functions/generateLinked import LinkedTextOptions from "../../../typedoc/interfaces/LinkedTextOptions.md?raw"; const meta = { - title: "utils/linkify", + title: "Core/Linkify", parameters: { docs: { page: () => ( diff --git a/packages/shared-components/src/core/utils/numbers.stories.tsx b/packages/shared-components/src/core/utils/numbers.stories.tsx index 72cd7c5ce4..b1ee55b22d 100644 --- a/packages/shared-components/src/core/utils/numbers.stories.tsx +++ b/packages/shared-components/src/core/utils/numbers.stories.tsx @@ -16,7 +16,7 @@ import percentageWithinDoc from "../../../typedoc/functions/percentageWithin.md? import sumDoc from "../../../typedoc/functions/sum.md?raw"; const meta = { - title: "utils/numbers", + title: "Core/Numbers", parameters: { docs: { page: () => ( diff --git a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.module.css b/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.module.css deleted file mode 100644 index 86e67b73c7..0000000000 --- a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.module.css +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -.thumbnail { - /* Thumbnails are always limited to a maximum of 100px */ - max-width: 100px; - max-height: 100px; - /* Ensure we don't stretch the image */ - object-fit: cover; -} - -.link { - color: var(--cpd-color-text-link-external); - text-decoration-line: none; -} - -.container { - display: inline flex; - column-gap: var(--cpd-space-1x); - border-inline-start: 2px solid var(--cpd-color-bg-subtle-primary); - border-radius: 2px; - color: var(--cpd-color-gray-900); - - .wrapImageCaption { - display: inline-flex; - flex-direction: row; - flex-wrap: wrap; - row-gap: var(--cpd-space-2x); - flex: 1; - } - - .image, - .caption { - display: inline-flex; - flex-direction: column; - margin-inline-start: var(--cpd-space-4x); - min-width: 0; /* Prevent blowout */ - } - - .image { - /* Clear default - ); - } - - const anchor = ( - - {preview.title} - - ); - return ( -
          -
          - {img} -
          - - {tooltipCaption ? {anchor} : anchor} - {preview.siteName && ( - - {" - " + preview.siteName} - - )} - - {preview.description && ( - {preview.description} - )} -
          -
          -
          - ); -} diff --git a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/__snapshots__/LinkPreview.test.tsx.snap b/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/__snapshots__/LinkPreview.test.tsx.snap deleted file mode 100644 index 1e0d988f1b..0000000000 --- a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/__snapshots__/LinkPreview.test.tsx.snap +++ /dev/null @@ -1,121 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`LinkPreview > renders a preview 1`] = ` -
          -
          -
          - -
          -

          - - A simple title - - - - Site name - -

          -

          - A simple description -

          -
          -
          -
          -
          -`; - -exports[`LinkPreview > renders a preview with just a title 1`] = ` -
          -
          - -
          -
          -`; - -exports[`LinkPreview > renders a preview with just a title and description 1`] = ` -
          -
          -
          -
          -

          - - A simple title - -

          -

          - A simple description with a link to - - https://matrix.org - -

          -
          -
          -
          -
          -`; diff --git a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/__snapshots__/UrlPreviewGroupView.test.tsx.snap b/packages/shared-components/src/event-tiles/UrlPreviewGroupView/__snapshots__/UrlPreviewGroupView.test.tsx.snap deleted file mode 100644 index fb9e9494d5..0000000000 --- a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/__snapshots__/UrlPreviewGroupView.test.tsx.snap +++ /dev/null @@ -1,492 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`UrlPreviewGroupView > renders a single preview 1`] = ` -
          -
          -
          -
          -
          - -
          -

          - - A simple title - -

          -

          - A simple description -

          -
          -
          -
          -
          - -
          -
          -`; - -exports[`UrlPreviewGroupView > renders multiple previews 1`] = ` -
          -
          -
          -
          -
          - -
          -

          - - One - -

          -

          - A regular square image. -

          -
          -
          -
          -
          -
          - -
          -

          - - Two - -

          -

          - This one has a taller image which should crop nicely. -

          -
          -
          -
          -
          -
          - -
          -

          - - Three - -

          -

          - One more description -

          -
          -
          -
          - -
          - -
          -
          -`; - -exports[`UrlPreviewGroupView > renders multiple previews which are hidden 1`] = ` -
          -
          -
          -
          -
          - -
          -

          - - A simple title - -

          -

          - A simple description -

          -
          -
          -
          - -
          - -
          -
          -`; - -exports[`UrlPreviewGroupView > renders with a compact view 1`] = ` -
          -
          -
          -
          -
          - -
          -

          - - One - -

          -

          - A regular square image. -

          -
          -
          -
          -
          -
          - -
          -

          - - Two - -

          -

          - This one has a taller image which should crop nicely. -

          -
          -
          -
          -
          -
          - -
          -

          - - Three - -

          -

          - One more description -

          -
          -
          -
          - -
          - -
          -
          -`; diff --git a/packages/shared-components/src/i18n/strings/cs.json b/packages/shared-components/src/i18n/strings/cs.json index 7da4a645ca..0b475e803c 100644 --- a/packages/shared-components/src/i18n/strings/cs.json +++ b/packages/shared-components/src/i18n/strings/cs.json @@ -3,30 +3,50 @@ "seek_bar_label": "Panel posunu zvuku" }, "action": { + "back": "Zpět", + "click": "Klikněte", + "close": "Zavřít", + "collapse": "Sbalit", "delete": "Smazat", "dismiss": "Zavřít", + "download": "Stáhnout", "edit": "Upravit", "explore_rooms": "Procházet místnosti", "go": "Přejít", + "hide": "Skrýt", "invite": "Pozvat", "new_conversation": "Nová konverzace", "new_room": "Nová místnost", + "new_section": "Nová sekce", "new_video_room": "Nová video místnost", "open_menu": "Otevřít nabídku", "pause": "Pozastavit", + "pin": "Připnout", "play": "Přehrát", + "react": "Reagovat", "remove": "Odstranit", + "reply": "Odpovědět", + "reply_in_thread": "Odpovědět ve vlákně", "retry": "Zkusit znovu", "search": "Hledání", - "start_chat": "Zahájit konverzaci" + "start_chat": "Zahájit konverzaci", + "unpin": "Odepnout", + "view_source": "Zobrazit zdroj" }, "common": { + "attachment": "Příloha", "encryption_enabled": "Šifrování povoleno", + "loading": "Načítání…", + "options": "Možnosti", "preferences": "Předvolby", "state_encryption_enabled": "Experimentální šifrování stavu povoleno" }, + "keyboard": { + "shift": "Shift" + }, "left_panel": { - "open_dial_pad": "Otevřít číselník" + "open_dial_pad": "Otevřít číselník", + "separator_label": "Kliknutím nebo přetažením rozbalíte" }, "notifications": { "all_messages": "Všechny zprávy", @@ -46,6 +66,7 @@ "jump_to_date": "Přejít na datum", "jump_to_date_beginning": "Začátek místnosti", "jump_to_date_prompt": "Vyberte datum, na které chcete přejít", + "pinned_message_badge": "Připnutá zpráva", "status_bar": { "delete_all": "Smazat vše", "exceeded_resource_limit_description": "Chcete-li službu nadále používat, obraťte se na správce služby.", @@ -75,7 +96,9 @@ "few": "", "other": "Otevřít místnost %(roomName)s s %(count)s nepřečtenými zprávami." }, - "unsent_message": "Otevřít místnost %(roomName)s s neodeslanou zprávou." + "unsent_message": "Otevřít místnost %(roomName)s s neodeslanou zprávou.", + "video_call": "Otevřete místnost %(roomName)s prostřednictvím videohovoru.", + "voice_call": "Otevřete místnost %(roomName)s pomocí hlasového hovoru." }, "appearance": "Vzhled", "collapse_filters": "Sbalit seznam filtrů", @@ -113,7 +136,8 @@ "leave_room": "Opustit místnost", "low_priority": "Nízká priorita", "mark_read": "Označit jako přečtené", - "mark_unread": "Označit jako nepřečtené" + "mark_unread": "Označit jako nepřečtené", + "move_to_section": "Přejít na" }, "notification_options": "Možnosti oznámení", "open_space_menu": "Otevřít nabídku prostoru", @@ -122,6 +146,11 @@ "more_options": "Více možností" }, "room_options": "Možnosti místnosti", + "section_created": "Sekce vytvořena", + "section_header": { + "toggle": "Přepnout %(section)s sekci", + "toggle_unread": "Přepnout sekci %(section)s s nepřečtenými místnostmi" + }, "show_message_previews": "Zobrazit náhledy zpráv", "sort": "Řadit", "sort_type": { @@ -137,6 +166,9 @@ "terms": { "tac_button": "Přečíst smluvní podmínky" }, + "threads": { + "error_start_thread_existing_relation": "Nelze vytvořit vlákno z události s existujícím vztahem" + }, "time": { "about_day_ago": "před jedním dnem", "about_hour_ago": "asi před hodinou", @@ -159,15 +191,20 @@ "historical_event_no_key_backup": "Historické zprávy nejsou na tomto zařízení k dispozici", "historical_event_unverified_device": "Pro přístup k historickým zprávám musíte toto zařízení ověřit", "historical_event_user_not_joined": "Nemáte přístup k této zprávě", - "sender_identity_previously_verified": "Ověřená identita odesílatele se změnila", + "sender_identity_previously_verified": "Ověřená digitální identita odesílatele byla resetována", "sender_unsigned_device": "Odesláno z nezabezpečeného zařízení.", "unable_to_decrypt": "Nepodařilo se dešifrovat zprávu" }, + "download_action_decrypting": "Dešifrování", + "download_action_downloading": "Stahování", "m.audio": { "audio_player": "Audio přehrávač", "error_downloading_audio": "Chyba při stahování audia", "unnamed_audio": "Nepojmenovaný audio soubor" }, + "m.file": { + "error_invalid": "Neplatný soubor" + }, "m.room.encryption": { "disable_attempt": "Ignorovaný pokus o deaktivaci šifrování", "disabled": "Šifrování není povoleno", @@ -178,8 +215,24 @@ "state_enabled": "Zprávy a události v této místnosti jsou koncově šifrovány. Když se lidé připojí, můžete je ověřit v jejich profilu, stačí klepnout na jejich profilový obrázek.", "unsupported": "Šifrování používané v této místnosti není podporováno." }, + "mab": { + "collapse_reply_chain": "Sbalit citace", + "copy_link_thread": "Kopírovat odkaz na vlákno", + "expand_reply_chain": "Rozbalit citace", + "label": "Akce zprávy", + "view_in_room": "Zobrazit v místnosti" + }, "message_timestamp_received_at": "Přijato: %(dateTime)s", - "message_timestamp_sent_at": "Odesláno: %(dateTime)s" + "message_timestamp_sent_at": "Odesláno: %(dateTime)s", + "url_preview": { + "close": "Zavřít náhled", + "show_n_more": { + "one": "Zobrazit %(count)s další náhled", + "few": "Zobrazit %(count)s další náhledy", + "other": "Zobrazit %(count)s dalších náhledů" + }, + "view_image": "Zobrazit obrázek" + } }, "widget": { "context_menu": { diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index 7cd70dbb0d..d747318b37 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -5,6 +5,7 @@ "action": { "back": "Back", "click": "Click", + "close": "Close", "collapse": "Collapse", "delete": "Delete", "dismiss": "Dismiss", @@ -16,6 +17,7 @@ "invite": "Invite", "new_conversation": "New conversation", "new_room": "New room", + "new_section": "New section", "new_video_room": "New video room", "open_menu": "Open menu", "pause": "Pause", @@ -34,6 +36,7 @@ "common": { "attachment": "Attachment", "encryption_enabled": "Encryption enabled", + "loading": "Loading…", "options": "Options", "preferences": "Preferences", "state_encryption_enabled": "Experimental state encryption enabled" @@ -96,6 +99,7 @@ "voice_call": "Open room %(roomName)s with a voice call." }, "appearance": "Appearance", + "chat_moved": "Chat moved", "collapse_filters": "Collapse filter list", "empty": { "no_chats": "No chats yet", @@ -131,7 +135,8 @@ "leave_room": "Leave room", "low_priority": "Low priority", "mark_read": "Mark as read", - "mark_unread": "Mark as unread" + "mark_unread": "Mark as unread", + "move_to_section": "Move to" }, "notification_options": "Notification options", "open_space_menu": "Open space menu", @@ -140,7 +145,11 @@ "more_options": "More Options" }, "room_options": "Room Options", + "section_created": "Section created", "section_header": { + "edit_section": "Edit section", + "more_options": "More options", + "remove_section": "Remove section", "toggle": "Toggle %(section)s section", "toggle_unread": "Toggle %(section)s section with unread room(s)" }, @@ -219,6 +228,7 @@ "message_timestamp_sent_at": "Sent at: %(dateTime)s", "url_preview": { "close": "Close preview", + "open_link": "Open link", "show_n_more": { "one": "Show %(count)s other preview", "other": "Show %(count)s other previews" diff --git a/packages/shared-components/src/i18n/strings/et.json b/packages/shared-components/src/i18n/strings/et.json index d267bc295a..015a1d6538 100644 --- a/packages/shared-components/src/i18n/strings/et.json +++ b/packages/shared-components/src/i18n/strings/et.json @@ -119,7 +119,7 @@ "show_message_previews": "Näita sõnumite eelvaateid", "sort": "Järjesta", "sort_type": { - "activity": "Aktiivsuse alusel", + "activity": "Viimatine aktiivsus", "atoz": "Tähestiku järjekorras", "unread_first": "Esmalt lugemata" }, diff --git a/packages/shared-components/src/i18n/strings/fr.json b/packages/shared-components/src/i18n/strings/fr.json index b9583de5b4..2fe4d11c69 100644 --- a/packages/shared-components/src/i18n/strings/fr.json +++ b/packages/shared-components/src/i18n/strings/fr.json @@ -5,6 +5,7 @@ "action": { "back": "Retour", "click": "Clic", + "close": "Fermer", "collapse": "Réduire", "delete": "Supprimer", "dismiss": "Ignorer", @@ -16,6 +17,7 @@ "invite": "Inviter", "new_conversation": "Nouvelle conversation", "new_room": "Nouveau salon", + "new_section": "Nouvelle section", "new_video_room": "Nouveau salon visio", "open_menu": "Ouvrir le menu", "pause": "Pause", @@ -34,6 +36,7 @@ "common": { "attachment": "Pièce jointe", "encryption_enabled": "Chiffrement activé", + "loading": "Chargement…", "options": "Options", "preferences": "Préférences", "state_encryption_enabled": "Chiffrement expérimental de l'état activé" @@ -131,7 +134,8 @@ "leave_room": "Quitter le salon", "low_priority": "Priorité basse", "mark_read": "Marquer comme lu", - "mark_unread": "Marquer comme non lu" + "mark_unread": "Marquer comme non lu", + "move_to_section": "Déplacer vers" }, "notification_options": "Paramètres de notifications", "open_space_menu": "Ouvrir le menu de l’espace", @@ -140,6 +144,7 @@ "more_options": "Plus d’options" }, "room_options": "Options du salon", + "section_created": "Section créée", "section_header": { "toggle": "Afficher/masquer la section %(section)s", "toggle_unread": "Afficher ou masquer la section « %(section)s s » contenant un ou plusieurs salon non lus" diff --git a/packages/shared-components/src/i18n/strings/zh_Hans.json b/packages/shared-components/src/i18n/strings/zh_Hans.json index 7377fbbf45..0a4469c2e7 100644 --- a/packages/shared-components/src/i18n/strings/zh_Hans.json +++ b/packages/shared-components/src/i18n/strings/zh_Hans.json @@ -1,14 +1,153 @@ { + "a11y": { + "seek_bar_label": "音频定位栏" + }, "action": { + "back": "返回", + "click": "点击", + "close": "关闭", + "collapse": "折叠", "delete": "删除", "dismiss": "忽略", + "download": "下载", + "edit": "编辑", "explore_rooms": "查找房间", + "go": "转到", + "hide": "隐藏", + "invite": "邀请", + "new_conversation": "新对话", + "new_room": "新房间", + "new_section": "新区域", + "new_video_room": "新视频房间", + "open_menu": "打开菜单", "pause": "暂停", + "pin": "置顶", "play": "播放", - "search": "搜索" + "react": "反应", + "remove": "移除", + "reply": "回复", + "reply_in_thread": "在消息列中回复", + "retry": "重试", + "search": "搜索", + "start_chat": "开始聊天", + "unpin": "取消置顶", + "view_source": "查看源代码" + }, + "common": { + "attachment": "附件", + "encryption_enabled": "加密已启用", + "loading": "正在载入…", + "options": "选项", + "preferences": "偏好", + "state_encryption_enabled": "实验性的状态加密已启用" }, "left_panel": { - "open_dial_pad": "打开拨号键盘" + "open_dial_pad": "打开拨号键盘", + "separator_label": "点击或拖动以展开" + }, + "notifications": { + "all_messages": "所有消息", + "default_settings": "跟随系统设置", + "mentions_keywords": "提及与关键词", + "mute_room": "静默房间" + }, + "room": { + "context_menu": { + "title": "房间选项" + }, + "history_visibility_badge": { + "private": "新成员不能看到历史", + "shared": "新成员可以看到历史", + "world_readable": "任何人都可以看到历史" + }, + "jump_to_date": "跳转到日期", + "jump_to_date_beginning": "房间的开头", + "jump_to_date_prompt": "选择日期以跳转", + "pinned_message_badge": "已被置顶的消息", + "status_bar": { + "delete_all": "全部删除", + "exceeded_resource_limit_description": "要继续使用此服务请联系服务器管理员。", + "exceeded_resource_limit_title": "你的消息未能发送,因为此服务器已超出资源限制。", + "failed_to_create_room_title": "无法与此用户开始聊天", + "homeserver_blocked_title": "你的消息未能发送,因为此服务器已被其管理员屏蔽。", + "monthly_user_limit_reached_title": "你的消息未能发送,因为此服务器已达到每月活跃用户上限。", + "requires_consent_agreement_title": "你需要同意我们的条款与条件才能发送任意消息。", + "retry_all": "全部重试", + "select_messages_to_retry": "你可以选择全部或个别消息以重试或删除。", + "server_connectivity_lost_description": "已发送的消息将保存直到恢复连接。", + "server_connectivity_lost_title": "已断开与服务器的连接。", + "some_messages_not_sent": "你的某些消息未能发送。" + } + }, + "room_list": { + "appearance": "外观", + "chat_moved": "聊天已移动", + "collapse_filters": "折叠过滤器列表", + "empty": { + "no_chats": "暂无聊天", + "no_chats_description": "首先向人们发送消息或创建房间。", + "no_chats_description_no_room_rights": "开始向某人发送消息", + "no_favourites": "你还没有收藏聊天", + "no_favourites_description": "你可以在聊天设置中将聊天添加到收藏夹", + "no_invites": "你没有任何未读邀请", + "no_lowpriority": "你没有任何低优先级房间", + "no_mentions": "你没有任何未读提及", + "no_people": "你尚未与任何人私聊", + "no_people_description": "你可以取消选择筛选条件,以便查看其它聊天记录", + "no_rooms": "尚未处于任何房间", + "no_rooms_description": "你可以取消选择筛选条件,以便查看其它聊天记录", + "no_unread": "恭喜!你没有任何未读消息", + "show_activity": "查看所有活动", + "show_chats": "显示所有聊天" + }, + "expand_filters": "展开过滤器列表", + "filters": { + "favourite": "收藏", + "invites": "邀请", + "low_priority": "低优先级", + "mentions": "提及", + "people": "人员", + "rooms": "房间", + "unread": "未读" + }, + "list_title": "房间列表", + "more_options": { + "copy_link": "复制房间链接", + "favourited": "已收藏", + "leave_room": "离开房间", + "low_priority": "低优先级", + "mark_read": "设为已读", + "mark_unread": "设为未读", + "move_to_section": "移动到" + }, + "notification_options": "通知选项", + "open_space_menu": "打开空间菜单", + "primary_filters": "房间列表筛选器", + "room": { + "more_options": "更多选项" + }, + "room_options": "房间选项", + "section_created": "区域已创建", + "section_header": { + "toggle": "切换区域 %(section)s", + "toggle_unread": "切换到区域 %(section)s 中的未读房间" + }, + "show_message_previews": "显示消息预览", + "sort": "排序", + "sort_type": { + "activity": "活动", + "unread_first": "未读优先" + }, + "space_menu": { + "home": "空间主页", + "space_settings": "空间设置" + } + }, + "terms": { + "tac_button": "阅读条款与条件" + }, + "threads": { + "error_start_thread_existing_relation": "无法从现有相关的事件中创建消息列" }, "time": { "about_day_ago": "约一天前", @@ -27,9 +166,62 @@ "n_minutes_ago": "%(num)s分钟前" }, "timeline": { + "decryption_failure": { + "blocked": "由于你的设备未经验证,发件人已阻止你接收此消息。", + "historical_event_no_key_backup": "消息历史在此设备上不可用", + "historical_event_unverified_device": "你需要验证此设备才能访问历史消息", + "historical_event_user_not_joined": "你无权访问此消息", + "sender_identity_previously_verified": "发送者的数字身份已重置。", + "sender_unsigned_device": "从不安全的设备发送。", + "unable_to_decrypt": "无法解密消息" + }, + "download_action_decrypting": "正在解密", + "download_action_downloading": "正在下载", "m.audio": { + "audio_player": "音频播放器", "error_downloading_audio": "下载音频时出错", "unnamed_audio": "未命名的音频" + }, + "m.file": { + "error_invalid": "无效文件" + }, + "m.room.encryption": { + "disable_attempt": "已忽略尝试禁用加密", + "disabled": "加密未启用", + "enabled": "此处的消息是端到端加密的。当人员加入时,你可以在其个人资料中点击其头像验证。", + "enabled_dm": "此处的消息是端到端加密的。点击其个人资料图像以验证 %(displayName)s。", + "enabled_local": "此聊天中的消息将被端到端加密。", + "parameters_changed": "某些加密参数已被更改。", + "state_enabled": "此房间内的消息与状态事件已被端到端加密。当人员加入时,你可以点击他们的头像以验证其身份。", + "unsupported": "该房间使用的加密方式不受支持。" + }, + "mab": { + "collapse_reply_chain": "折叠引用", + "copy_link_thread": "复制关联到此消息列的链接", + "expand_reply_chain": "展开引用", + "label": "消息操作", + "view_in_room": "在房间中查看" + }, + "message_timestamp_received_at": "接收于 %(dateTime)s", + "message_timestamp_sent_at": "发送于 %(dateTime)s", + "url_preview": { + "close": "关闭预览", + "open_link": "打开链接", + "show_n_more": { + "one": "显示剩余 %(count)s 个预览", + "other": "显示剩余 %(count)s 个预览" + }, + "view_image": "查看图像" + } + }, + "widget": { + "context_menu": { + "move_left": "向左移动", + "move_right": "向右移动", + "remove": "为所有人移除", + "revoke": "撤消权限", + "screenshot": "拍摄照片", + "start_audio_stream": "开始音频串流" } } } diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 64c504c5fb..1cfaabd3b8 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -10,13 +10,14 @@ export * from "./audio/Clock"; export * from "./audio/PlayPauseButton"; export * from "./audio/SeekBar"; export * from "./core/AvatarWithDetails"; +export * from "./core/roving"; export * from "./room/composer/Banner"; export * from "./crypto/SasEmoji"; -export * from "./event-tiles/UrlPreviewGroupView"; export * from "./room/timeline/ReadMarker"; export * from "./room/timeline/event-tile/body/EventContentBodyView"; export * from "./room/timeline/event-tile/body/RedactedBodyView"; export * from "./room/timeline/event-tile/body/MFileBodyView"; +export * from "./room/timeline/event-tile/body/MImageBodyView"; export * from "./room/timeline/event-tile/body/MVideoBodyView"; export * from "./room/timeline/event-tile/body/TextualBodyView"; export * from "./room/timeline/event-tile/EventTileView/TileErrorView"; @@ -41,6 +42,7 @@ export * from "./room/timeline/event-tile/reactions/ReactionsRow"; export * from "./room/timeline/event-tile/reactions/ReactionsRowButton"; export * from "./room/timeline/event-tile/reactions/ReactionsRowButtonTooltip"; export * from "./room/timeline/event-tile/timestamp/MessageTimestampView"; +export * from "./room/timeline/event-tile/UrlPreviewGroupView"; export * from "./core/rich-list/RichItem"; export * from "./core/rich-list/RichList"; export * from "./room-list/RoomListHeaderView"; diff --git a/packages/shared-components/src/room-list/RoomListHeaderView/RoomListHeaderView.stories.tsx b/packages/shared-components/src/room-list/RoomListHeaderView/RoomListHeaderView.stories.tsx index 35b5fd201b..83551bc21b 100644 --- a/packages/shared-components/src/room-list/RoomListHeaderView/RoomListHeaderView.stories.tsx +++ b/packages/shared-components/src/room-list/RoomListHeaderView/RoomListHeaderView.stories.tsx @@ -30,6 +30,7 @@ const RoomListHeaderViewWrapperImpl = ({ openSpacePreferences, sort, toggleMessagePreview, + createSection, ...rest }: RoomListHeaderProps): JSX.Element => { const vm = useMockedViewModel(rest, { @@ -42,6 +43,7 @@ const RoomListHeaderViewWrapperImpl = ({ sort, openSpacePreferences, toggleMessagePreview, + createSection, }); return ; }; @@ -62,6 +64,7 @@ const meta = { sort: fn(), openSpacePreferences: fn(), toggleMessagePreview: fn(), + createSection: fn(), }, parameters: { design: { @@ -100,3 +103,9 @@ export const LongTitle: Story = { title: "Loooooooooooooooooooooooooooooooooooooong title", }, }; + +export const PlusIcon: Story = { + args: { + useComposeIcon: false, + }, +}; diff --git a/packages/shared-components/src/room-list/RoomListHeaderView/RoomListHeaderView.tsx b/packages/shared-components/src/room-list/RoomListHeaderView/RoomListHeaderView.tsx index 2125ea9af2..05254899ea 100644 --- a/packages/shared-components/src/room-list/RoomListHeaderView/RoomListHeaderView.tsx +++ b/packages/shared-components/src/room-list/RoomListHeaderView/RoomListHeaderView.tsx @@ -8,6 +8,7 @@ import React, { type JSX } from "react"; import { IconButton, H1 } from "@vector-im/compound-web"; import ComposeIcon from "@vector-im/compound-design-tokens/assets/web/icons/compose"; +import PlusIcon from "@vector-im/compound-design-tokens/assets/web/icons/plus"; import { type ViewModel, useViewModel } from "../../core/viewmodel"; import { Flex } from "../../core/utils/Flex"; @@ -59,6 +60,14 @@ export interface RoomListHeaderViewSnapshot { * Whether message previews are enabled in the room list. */ isMessagePreviewEnabled: boolean; + /** + * Whether the user can create sections in the room list. + */ + canCreateSection: boolean; + /** + * Whether to use the compose icon instead of the create icon. + */ + useComposeIcon: boolean; } export interface RoomListHeaderViewActions { @@ -98,6 +107,10 @@ export interface RoomListHeaderViewActions { * Toggle message preview display in the room list. */ toggleMessagePreview: () => void; + /** + * Create a new section in the room list. + */ + createSection: () => void; } /** @@ -123,7 +136,7 @@ interface RoomListHeaderViewProps { */ export function RoomListHeaderView({ vm }: Readonly): JSX.Element { const { translate: _t } = useI18n(); - const { title, displaySpaceMenu, displayComposeMenu } = useViewModel(vm); + const { title, displaySpaceMenu, displayComposeMenu, useComposeIcon } = useViewModel(vm); return ( ): J onClick={(e) => vm.createChatRoom(e.nativeEvent)} tooltip={_t("action|new_conversation")} > - + {useComposeIcon ? ( + + ) : ( + + )} )} diff --git a/packages/shared-components/src/room-list/RoomListHeaderView/__snapshots__/RoomListHeaderView.test.tsx.snap b/packages/shared-components/src/room-list/RoomListHeaderView/__snapshots__/RoomListHeaderView.test.tsx.snap index 01c5914797..6608b5b3f3 100644 --- a/packages/shared-components/src/room-list/RoomListHeaderView/__snapshots__/RoomListHeaderView.test.tsx.snap +++ b/packages/shared-components/src/room-list/RoomListHeaderView/__snapshots__/RoomListHeaderView.test.tsx.snap @@ -110,6 +110,7 @@ exports[`RoomListHeaderView > renders the default state 1`] = ` > - + {useComposeIcon ? ( + + ) : ( + + )} } > @@ -63,6 +69,9 @@ export function ComposeMenuView({ vm }: ComposeMenuViewProps): JSX.Element { hideChevron /> )} + {canCreateSection && ( + + )} ); } diff --git a/packages/shared-components/src/room-list/RoomListHeaderView/menu/__snapshots__/ComposeMenuView.test.tsx.snap b/packages/shared-components/src/room-list/RoomListHeaderView/menu/__snapshots__/ComposeMenuView.test.tsx.snap index d269845351..2f43a667bc 100644 --- a/packages/shared-components/src/room-list/RoomListHeaderView/menu/__snapshots__/ComposeMenuView.test.tsx.snap +++ b/packages/shared-components/src/room-list/RoomListHeaderView/menu/__snapshots__/ComposeMenuView.test.tsx.snap @@ -22,6 +22,7 @@ exports[` > should match snapshot 1`] = ` >
          + +
          + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const SectionCreated: Story = {}; + +export const ChatMoved: Story = { + args: { + type: "chat_moved", + }, +}; diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.test.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.test.tsx new file mode 100644 index 0000000000..e9f4303e52 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.test.tsx @@ -0,0 +1,36 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; +import { render, screen } from "@test-utils"; +import { composeStories } from "@storybook/react-vite"; +import { describe, it, expect } from "vitest"; +import userEvent from "@testing-library/user-event"; + +import * as stories from "./RoomListToast.stories"; + +const { SectionCreated, ChatMoved } = composeStories(stories); + +describe("", () => { + it("renders SectionCreated story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders ChatMoved story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("calls onClose when the close button is clicked", async () => { + const user = userEvent.setup(); + render(); + const closeButton = screen.getByRole("button", { name: "Close" }); + await user.click(closeButton); + expect(SectionCreated.args.onClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.tsx new file mode 100644 index 0000000000..e46b000fd4 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type ComponentType, type JSX, type MouseEventHandler } from "react"; +import { Toast } from "@vector-im/compound-web"; +import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check"; + +import styles from "./RoomListToast.module.css"; +import { useI18n } from "../../../core/i18n/i18nContext"; + +export type ToastType = "section_created" | "chat_moved"; + +interface RoomListToastProps { + /** The type of toast to display */ + type: ToastType; + /** Callback when the close button is clicked */ + onClose: MouseEventHandler; +} + +/** + * A toast component used for displaying temporary messages in the room list view. + * + * @example + * ```tsx + * + * ``` + */ +export function RoomListToast({ type, onClose }: Readonly): JSX.Element { + const { translate: _t } = useI18n(); + + let content: { text: string; icon: ComponentType> }; + switch (type) { + case "section_created": + content = { text: _t("room_list|section_created"), icon: CheckIcon }; + break; + case "chat_moved": + content = { text: _t("room_list|chat_moved"), icon: CheckIcon }; + break; + } + + return ( + + {content.text} + + ); +} diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListToast/__snapshots__/RoomListToast.test.tsx.snap b/packages/shared-components/src/room-list/RoomListView/RoomListToast/__snapshots__/RoomListToast.test.tsx.snap new file mode 100644 index 0000000000..05d35de67a --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListView/RoomListToast/__snapshots__/RoomListToast.test.tsx.snap @@ -0,0 +1,113 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders ChatMoved story 1`] = ` +
          +
          +
          +
          + + Chat moved +
          + +
          +
          +
          +`; + +exports[` > renders SectionCreated story 1`] = ` +
          +
          +
          +
          + + Section created +
          + +
          +
          +
          +`; diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListToast/index.ts b/packages/shared-components/src/room-list/RoomListView/RoomListToast/index.ts new file mode 100644 index 0000000000..3a6b6a5cf5 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListView/RoomListToast/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export type { ToastType } from "./RoomListToast"; +export { RoomListToast } from "./RoomListToast"; diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.module.css b/packages/shared-components/src/room-list/RoomListView/RoomListView.module.css new file mode 100644 index 0000000000..c20d3006bf --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.module.css @@ -0,0 +1,11 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.list { + position: relative; + flex: 1; +} diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.stories.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListView.stories.tsx index 6f683225d0..dfdab19c92 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListView.stories.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.stories.tsx @@ -39,6 +39,7 @@ const RoomListViewWrapperImpl = ({ getSectionHeaderViewModel, updateVisibleRooms, renderAvatar: renderAvatarProp, + closeToast, ...rest }: RoomListViewProps): JSX.Element => { const vm = useMockedViewModel(rest, { @@ -48,6 +49,7 @@ const RoomListViewWrapperImpl = ({ getRoomItemViewModel, getSectionHeaderViewModel, updateVisibleRooms, + closeToast, }); return ; }; @@ -98,6 +100,8 @@ const meta = { updateVisibleRooms: fn(), renderAvatar, isFlatList: true, + toast: undefined, + closeToast: fn(), }, parameters: { design: { @@ -245,3 +249,9 @@ export const LargeSectionList: Story = { getSectionHeaderViewModel: createGetSectionHeaderViewModel(mockLargeListSections.map((section) => section.id)), }, }; + +export const Toast: Story = { + args: { + toast: "section_created", + }, +}; diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.test.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListView.test.tsx index 380600541a..5d84e05d05 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListView.test.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.test.tsx @@ -31,6 +31,7 @@ const { EmptyInvitesFilter, EmptyMentionsFilter, EmptyLowPriorityFilter, + Toast, } = composeStories(stories); const renderWithMockContext = (component: React.ReactElement): ReturnType => { @@ -124,6 +125,11 @@ describe("", () => { expect(container).toMatchSnapshot(); }); + it("renders Toast story", () => { + const { container } = renderWithMockContext(); + expect(container).toMatchSnapshot(); + }); + it("should call onToggleFilter when filter is clicked", async () => { const user = userEvent.setup(); renderWithMockContext(); @@ -186,4 +192,13 @@ describe("", () => { expect(EmptyLowPriorityFilter.args.onToggleFilter).toHaveBeenCalled(); }); + + it("should call closeToast when close button is clicked on toast", async () => { + const user = userEvent.setup(); + renderWithMockContext(); + + await user.click(screen.getByRole("button", { name: "Close" })); + + expect(EmptyLowPriorityFilter.args.closeToast).toHaveBeenCalled(); + }); }); diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx index 132b9dfda3..3cace4b400 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx @@ -17,6 +17,9 @@ import { type RoomListItemViewModel, } from "../VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView"; import { type RoomListSectionHeaderViewModel } from "../VirtualizedRoomListView/RoomListSectionHeaderView"; +import { type ToastType, RoomListToast } from "./RoomListToast"; +import styles from "./RoomListView.module.css"; +import { Flex } from "../../core/utils/Flex"; export type RoomListSection = { /** Unique identifier for the section */ @@ -49,6 +52,8 @@ export type RoomListViewSnapshot = { canCreateRoom?: boolean; /** Whether the room list is displayed as a flat list */ isFlatList: boolean; + /** Optional toast to display */ + toast?: ToastType; }; /** @@ -70,6 +75,8 @@ export interface RoomListViewActions { updateVisibleRooms: (startIndex: number, endIndex: number) => void; /** Get view model for a specific section header (virtualization API) */ getSectionHeaderViewModel: (sectionId: string) => RoomListSectionHeaderViewModel; + /** Called to close the toast message */ + closeToast: () => void; } /** @@ -113,7 +120,10 @@ export const RoomListView: React.FC = ({ vm, renderAvatar, on onToggleFilter={vm.onToggleFilter} /> - {listBody} + + {listBody} + {snapshot.toast && } + ); }; diff --git a/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap b/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap index 388c0878ff..9534fa1080 100644 --- a/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap +++ b/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap @@ -58,2610 +58,2616 @@ exports[` > renders Default story 1`] = `
          -
          +
          + + +
          + -
          -
          - -
          -
          -
          +
          -
          - RA -
          -
          +
          - Random +
          + Random +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - - -
          -
          +
          -
          - EN -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - DE -
          -
          +
          - Design +
          + Design +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - PR -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - MA -
          -
          -
          +
          + + +
          + -
          -
          - -
          -
          -
          +
          -
          - SA -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - SU -
          -
          +
          - Support +
          + Support +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - AN -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - OF -
          -
          +
          - Off-topic +
          + Off-topic +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - TE -
          -
          -
          +
          + + +
          + -
          -
          - -
          -
          -
          +
          -
          - TE -
          -
          +
          - Team Beta +
          + Team Beta +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - PR -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - PR -
          -
          +
          - Project Y +
          + Project Y +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - WA -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - FE -
          -
          -
          +
          + + +
          + -
          -
          - -
          -
          -
          +
          -
          - ID -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - BU -
          -
          +
          - Bugs +
          + Bugs +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - FE -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - RE -
          -
          +
          - Releases +
          + Releases +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - + +
          @@ -2728,66 +2734,71 @@ exports[` > renders Empty story 1`] = `
          - - No chats yet - - - Get started by messaging someone or by creating a room -
          - - + + Start chat + + +
          @@ -2834,20 +2845,25 @@ exports[` > renders EmptyFavouriteFilter story 1`] = `
          - - You don't have favourite chats yet - - - You can add a chat to your favourites in the chat settings - + + You don't have favourite chats yet + + + You can add a chat to your favourites in the chat settings + +
          @@ -2893,24 +2909,29 @@ exports[` > renders EmptyInvitesFilter story 1`] = `
          - - You don't have any unread invites - - + + You don't have any unread invites + + +
          @@ -2956,24 +2977,29 @@ exports[` > renders EmptyLowPriorityFilter story 1`] = `
          - - You don't have any low priority rooms - - + + You don't have any low priority rooms + + +
          @@ -3019,24 +3045,29 @@ exports[` > renders EmptyMentionsFilter story 1`] = `
          - - You don't have any unread mentions - - + + You don't have any unread mentions + + +
          @@ -3082,20 +3113,25 @@ exports[` > renders EmptyPeopleFilter story 1`] = `
          - - You don’t have direct chats with anyone yet - - - You can deselect filters in order to see your other chats - + + You don’t have direct chats with anyone yet + + + You can deselect filters in order to see your other chats + +
          @@ -3141,20 +3177,25 @@ exports[` > renders EmptyRoomsFilter story 1`] = `
          - - You’re not in any room yet - - - You can deselect filters in order to see your other chats - + + You’re not in any room yet + + + You can deselect filters in order to see your other chats + +
          @@ -3200,24 +3241,29 @@ exports[` > renders EmptyUnreadFilter story 1`] = `
          - - Congrats! You don’t have any unread messages - - + + Congrats! You don’t have any unread messages + + +
          @@ -3281,45 +3327,50 @@ exports[` > renders EmptyWithoutCreatePermission story 1`] = `
          - - No chats yet - - - Get started by messaging someone -
          - + + Start chat + +
          @@ -3384,4830 +3435,4836 @@ exports[` > renders LargeFlatList story 1`] = `
          -
          +
          + + +
          + -
          -
          - -
          -
          -
          +
          -
          - RA -
          -
          +
          - Random +
          + Random +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - - -
          -
          +
          -
          - EN -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - DE -
          -
          +
          - Design +
          + Design +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - PR -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - MA -
          -
          -
          +
          + + +
          + -
          -
          - -
          -
          -
          +
          -
          - SA -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - SU -
          -
          +
          - Support +
          + Support +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - AN -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - OF -
          -
          +
          - Off-topic +
          + Off-topic +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - TE -
          -
          -
          +
          + + +
          + -
          -
          - -
          -
          -
          +
          -
          - TE -
          -
          +
          - Team Beta +
          + Team Beta +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - PR -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - PR -
          -
          +
          - Project Y +
          + Project Y +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - WA -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - FE -
          -
          -
          +
          + + +
          + -
          -
          - -
          -
          -
          +
          -
          - ID -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - BU -
          -
          +
          - Bugs +
          + Bugs +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - FE -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - RE -
          -
          +
          - Releases +
          + Releases +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - GE -
          -
          -
          +
          + + +
          + -
          -
          - -
          -
          -
          +
          -
          - RA -
          -
          +
          - Random +
          + Random +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - EN -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - DE -
          -
          +
          - Design +
          + Design +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - PR -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - MA -
          -
          -
          +
          + + +
          + -
          -
          - -
          -
          -
          +
          -
          - SA -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - SU -
          -
          +
          - Support +
          + Support +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - AN -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - OF -
          -
          +
          - Off-topic +
          + Off-topic +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - TE -
          -
          -
          +
          + + +
          + -
          -
          - -
          -
          -
          +
          -
          - TE -
          -
          +
          - Team Beta +
          + Team Beta +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - PR -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - PR -
          -
          +
          - Project Y +
          + Project Y +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - WA -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - FE -
          -
          -
          +
          + + +
          + -
          -
          - -
          -
          -
          +
          -
          - ID -
          -
          - - +
          +
          + + +
          + - -
          - + +
          @@ -8274,4824 +8331,4842 @@ exports[` > renders LargeSectionList story 1`] = `
          - -
          -
          -
          -
          - - -
          - -
          - + +
          -
          -
          - - -
          - -
          - - - -
          -
          - - +
          - -
          - - - - - -
          -
          - - -
          - -
          - - - -
          -
          -
          - Last message in Product -
          -
          -
          - - -
          - - - - - -
          -
          - - -
          -
          - - + + - -
          - - + + + +
          + + +
          + - - - + + - -
          - - +
          - + + + +
          + + + - - - + + - -
          - - + + + +
          + + +
          + - - - + + - -
          - - +
          - + + + +
          + + + - - - + + - -
          -
          +
          + + +
          + -
          - - + + - -
          - - +
          - + + + +
          + + + - - - + + - -
          - - + + + +
          + + +
          + - - - + + - -
          - - +
          - + + + +
          + + + - - - + + - -
          - - + + + +
          + + +
          + - - - + + - -
          -
          +
          + + +
          + -
          - - + + - -
          - - + + + +
          + + +
          + - - - + + - -
          - - +
          - + + + +
          + + + - - - + + - -
          - - + + + +
          + + +
          + - - - + + - -
          - - +
          - + + + +
          + + + - - - + + - -
          -
          +
          + + +
          + -
          - - + + - -
          - - +
          - + + + +
          + + + - - - + + - -
          - - + + + +
          + + +
          + - - - + + - -
          - + +
          + +
          + + + +
          +
          + + +
          + +
          + + + +
          +
          + + +
          + +
          + + + + +
          +
          + + +
          + +
          + + + +
          +
          + + +
          + +
          + + + +
          +
          -
          - - - - - Chats - -
          - -
          -
          -
          -
          - - -
          - -
          - + + - -
          - - + + + +
          + + +
          + - - - + + - -
          - - -
          - -
          - + + - -
          -
          +
          + + +
          +
          - - - + + - -
          - - +
          - + + + +
          + + + - - - + + - -
          - - + + + +
          + + +
          + - - - + + - -
          - - +
          - + + + +
          + + + - - - + + - -
          - - -
          - +
          + + +
          +
          - - + + - -
          - + +
          +
          -
          - - -
          - - - + + - -
          - - + + + +
          + + +
          + - - - + + - -
          - - +
          - + + + +
          + + + - - - + + - -
          - + +
          +
          + + + +
          +
          + - +
          +
          + + +
          + - -
          - + + @@ -13159,8 +13234,13 @@ exports[` > renders Loading story 1`] = `
          + class="Flex-module_flex RoomListView-module_list" + style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" + > +
          +
          `; @@ -13223,291 +13303,297 @@ exports[` > renders SmallFlatList story 1`] = `
          -
          +
          + + +
          + -
          -
          - -
          -
          -
          +
          -
          - RA -
          -
          +
          - Random +
          + Random +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - + + @@ -13574,87 +13660,513 @@ exports[` > renders SmallSectionList story 1`] = `
          -
          + +
          +
          +
          +
          + +
          + GE +
          +
          +
          +
          + General +
          +
          + Last message in General +
          +
          +
          + + +
          + +
          + +
          +
          +
          +
          + + +
          + +
          + +
          +
          +
          +
          + +
          + + + + + +`; + +exports[` > renders Toast story 1`] = ` +
          +
          +
          +
          +
          + + + + +
          +
          +
          +
          +
          +
          -
          -
          +
          + +
          + +
          +
          + + +
          + +
          + +
          +
          + + +
          + +
          + + +
          + + +
          + + + + +
          + + +
          + + + + + +
          + + +
          + + + + +
          + + +
          + + + + +
          + + +
          + + + + +
          + + +
          + + + + +
          + + +
          + + + + + +
          + + +
          + + + + +
          + + +
          + + + + +
          + + +
          + + + + +
          + + +
          + + + + +
          + + +
          + + + + + +
          + + +
          + + + + +
          + + +
          + + + + +
          + + +
          + + + + +
          + +
          -
          -
          - -
          -
          +
          +
          + + Section created +
          + +
          @@ -14028,2610 +16855,2616 @@ exports[` > renders WithActiveFilter story 1`] = `
          -
          +
          + + +
          + -
          -
          - -
          -
          -
          +
          -
          - RA -
          -
          +
          - Random +
          + Random +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - - -
          -
          +
          -
          - EN -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - DE -
          -
          +
          - Design +
          + Design +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - PR -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - MA -
          -
          -
          +
          + + +
          + -
          -
          - -
          -
          -
          +
          -
          - SA -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - SU -
          -
          +
          - Support +
          + Support +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - AN -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - OF -
          -
          +
          - Off-topic +
          + Off-topic +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - TE -
          -
          -
          +
          + + +
          + -
          -
          - -
          -
          -
          +
          -
          - TE -
          -
          +
          - Team Beta +
          + Team Beta +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - PR -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - PR -
          -
          +
          - Project Y +
          + Project Y +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - WA -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - FE -
          -
          -
          +
          + + +
          + -
          -
          - -
          -
          -
          +
          -
          - ID -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - BU -
          -
          +
          - Bugs +
          + Bugs +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - -
          -
          -
          +
          -
          - FE -
          -
          - - +
          +
          + + +
          + - -
          - -
          -
          -
          +
          -
          - RE -
          -
          +
          - Releases +
          + Releases +
          -
          -
          - - +
          - + + + +
          + +
          + - -
          - + +
          diff --git a/packages/shared-components/src/room-list/RoomListView/index.tsx b/packages/shared-components/src/room-list/RoomListView/index.tsx index a4ebbaf7b7..9b7ee15528 100644 --- a/packages/shared-components/src/room-list/RoomListView/index.tsx +++ b/packages/shared-components/src/room-list/RoomListView/index.tsx @@ -17,3 +17,4 @@ export { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton"; export { RoomListEmptyStateView } from "./RoomListEmptyStateView"; export type { RoomListEmptyStateViewProps } from "./RoomListEmptyStateView"; export * from "../VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView"; +export * from "./RoomListToast"; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemAccessibilityWrapper.stories.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemAccessibilityWrapper.stories.tsx index 7e131593ba..bd70fb4d1b 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemAccessibilityWrapper.stories.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemAccessibilityWrapper.stories.tsx @@ -13,7 +13,7 @@ import { RoomListItemAccessibilityWrapper } from "./RoomListItemAccessibilityWra import { createMockRoomItemViewModel, renderAvatar } from "../../story-mocks"; const meta = { - title: "Room List/RoomListItemAccessibiltyWrapper", + title: "Room List/RoomListItemAccessibilityWrapper", component: RoomListItemAccessibilityWrapper, tags: ["autodocs"], args: { diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.test.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.test.tsx index ac13f7874e..a6d657b2a5 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.test.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.test.tsx @@ -10,7 +10,7 @@ import { render, screen } from "@test-utils"; import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi } from "vitest"; -import { RoomListItemMoreOptionsMenu } from "./RoomListItemMoreOptionsMenu"; +import { RoomListItemMoreOptionsMenu, MoreOptionContent } from "./RoomListItemMoreOptionsMenu"; import { useMockedViewModel } from "../../../../core/viewmodel"; import type { RoomListItemViewSnapshot } from "./RoomListItemView"; import { defaultSnapshot } from "./default-snapshot"; @@ -26,6 +26,8 @@ describe("", () => { onCopyRoomLink: vi.fn(), onLeaveRoom: vi.fn(), onSetRoomNotifState: vi.fn(), + onCreateSection: vi.fn(), + onToggleSection: vi.fn(), }; const renderMenu = (overrides: Partial = {}): ReturnType => { @@ -224,4 +226,74 @@ describe("", () => { expect(mockCallbacks.onLeaveRoom).toHaveBeenCalled(); }); + + it("should call onCreateSection when new section is clicked", async () => { + const user = userEvent.setup(); + // We need to render the MoreOptionContent directly here as radix is kind of messing in the test env + const TestComponent = (): JSX.Element => { + const vm = useMockedViewModel(defaultSnapshot, mockCallbacks); + return ; + }; + render(); + + const newSection = screen.getByRole("menuitem", { name: "New section" }); + await user.click(newSection); + + expect(mockCallbacks.onCreateSection).toHaveBeenCalled(); + }); + + it("should render section items in move to section submenu", () => { + const sections = [ + { tag: "m.favourite", name: "Favourites", isSelected: false }, + { tag: "element.io.section.custom1", name: "Work", isSelected: true }, + { tag: "element.io.section.custom2", name: "Personal", isSelected: false }, + ]; + + const TestComponent = (): JSX.Element => { + const vm = useMockedViewModel({ ...defaultSnapshot, sections }, mockCallbacks); + return ; + }; + render(); + + const favouriteItem = screen.getByRole("menuitem", { name: "Favourites" }); + expect(favouriteItem).toBeInTheDocument(); + expect(favouriteItem).toHaveAttribute("aria-checked", "false"); + + const workItem = screen.getByRole("menuitem", { name: "Work" }); + expect(workItem).toBeInTheDocument(); + expect(workItem).toHaveAttribute("aria-checked", "true"); + + const personalItem = screen.getByRole("menuitem", { name: "Personal" }); + expect(personalItem).toBeInTheDocument(); + expect(personalItem).toHaveAttribute("aria-checked", "false"); + }); + + it("should call onToggleSection when a section item is clicked", async () => { + const user = userEvent.setup(); + const sections = [ + { tag: "m.favourite", name: "Favourites", isSelected: false }, + { tag: "element.io.section.custom1", name: "Work", isSelected: false }, + ]; + + const TestComponent = (): JSX.Element => { + const vm = useMockedViewModel({ ...defaultSnapshot, sections }, mockCallbacks); + return ; + }; + render(); + + const workItem = screen.getByRole("menuitem", { name: "Work" }); + await user.click(workItem); + + expect(mockCallbacks.onToggleSection).toHaveBeenCalledWith("element.io.section.custom1"); + }); + + it("should not render section items when sections array is empty", () => { + const TestComponent = (): JSX.Element => { + const vm = useMockedViewModel({ ...defaultSnapshot, sections: [] }, mockCallbacks); + return ; + }; + render(); + + expect(screen.getByRole("menuitem", { name: "New section" })).toBeInTheDocument(); + }); }); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.tsx index 8a6286e01c..00690a445b 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.tsx @@ -6,7 +6,7 @@ */ import React, { useState, type JSX } from "react"; -import { IconButton, Menu, MenuItem, Separator, ToggleMenuItem } from "@vector-im/compound-web"; +import { IconButton, Menu, MenuItem, Separator, SubMenu, ToggleMenuItem } from "@vector-im/compound-web"; import { MarkAsReadIcon, MarkAsUnreadIcon, @@ -16,6 +16,8 @@ import { LinkIcon, LeaveIcon, OverflowHorizontalIcon, + ArrowRightIcon, + CheckIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import { _t } from "../../../../core/i18n/i18n"; @@ -106,6 +108,7 @@ export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element { onSelect={vm.onToggleLowPriority} onClick={(evt) => evt.stopPropagation()} /> + {snapshot.canInvite && ( )} + {snapshot.canMoveToSection && ( + + } + > + {snapshot.sections.map((section) => ( + vm.onToggleSection(section.tag)} + onClick={(evt) => evt.stopPropagation()} + hideChevron={true} + aria-checked={section.isSelected} + > + {section.isSelected && ( + + )} + + ))} + + + + )} ", () => { onCopyRoomLink: vi.fn(), onLeaveRoom: vi.fn(), onSetRoomNotifState: vi.fn(), + onCreateSection: vi.fn(), + onToggleSection: vi.fn(), }; const renderMenu = (roomNotifState: RoomNotifState = RoomNotifState.AllMessages): ReturnType => { diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemView.stories.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemView.stories.tsx index c6c7986ffb..21bf2806d2 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemView.stories.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemView.stories.tsx @@ -38,6 +38,8 @@ const RoomListItemWrapperImpl = ({ onCopyRoomLink, onLeaveRoom, onSetRoomNotifState, + onCreateSection, + onToggleSection, isSelected, isFocused, onFocus, @@ -56,6 +58,8 @@ const RoomListItemWrapperImpl = ({ onCopyRoomLink, onLeaveRoom, onSetRoomNotifState, + onCreateSection, + onToggleSection, }); return ( void; /** Called when setting the room notification state */ onSetRoomNotifState: (state: RoomNotifState) => void; + /** Called when creating a new section */ + onCreateSection: () => void; + /** Called when toggling a room's membership in a section */ + onToggleSection: (tag: string) => void; } /** diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/default-snapshot.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/default-snapshot.ts index 2ec961ff98..bf0cb0189e 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/default-snapshot.ts +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/default-snapshot.ts @@ -36,4 +36,22 @@ export const defaultSnapshot: RoomListItemViewSnapshot = { canMarkAsRead: false, canMarkAsUnread: true, roomNotifState: RoomNotifState.AllMessages, + canMoveToSection: true, + sections: [ + { + tag: "m.favourite", + name: "Favourites", + isSelected: false, + }, + { + tag: "element.io.section.work", + name: "Work", + isSelected: true, + }, + { + tag: "m.lowpriority", + name: "Low Priority", + isSelected: false, + }, + ], }; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/index.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/index.ts index 13db309fd0..72f2f98119 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/index.ts +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/index.ts @@ -12,6 +12,7 @@ export type { RoomListItemViewModel, RoomListItemViewActions, RoomListItemViewProps, + Section, } from "./RoomListItemView"; export { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu"; export type { RoomListItemNotificationMenuProps } from "./RoomListItemNotificationMenu"; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/mocked-actions.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/mocked-actions.ts index bda12ac114..8e79c52cc5 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/mocked-actions.ts +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/mocked-actions.ts @@ -19,4 +19,6 @@ export const mockedActions: RoomListItemViewActions = { onCopyRoomLink: fn(), onLeaveRoom: fn(), onSetRoomNotifState: fn(), + onCreateSection: fn(), + onToggleSection: fn(), }; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css index 5232b4299f..d587c8014f 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css @@ -19,7 +19,8 @@ background-color: var(--cpd-color-bg-canvas-default); &:hover, - &:focus-visible { + &:focus-visible, + &:has(button[data-state="open"]) { color: var(--cpd-color-text-primary); svg { @@ -29,20 +30,24 @@ .container { background-color: var(--cpd-color-bg-action-tertiary-hovered); } + + .menu { + display: initial; + } } - svg { + .chevron { transition: transform 0.05s linear; } @media (prefers-reduced-motion: reduce) { - svg { + .chevron { transition: none; } } &[aria-expanded="true"] { - svg { + .chevron { transform: rotate(90deg); } } @@ -58,6 +63,10 @@ padding: var(--cpd-space-1-5x) var(--cpd-space-2x) var(--cpd-space-1-5x) var(--cpd-space-1x); border-radius: 8px; + div { + min-width: 0; + } + svg { flex-shrink: 0; } @@ -77,3 +86,7 @@ .lastHeader { padding-bottom: 0; } + +.menu { + display: none; +} diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.stories.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.stories.tsx index 9b48f089c9..c4455dc036 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.stories.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.stories.tsx @@ -25,6 +25,8 @@ type RoomListSectionHeaderProps = RoomListSectionHeaderViewSnapshot & const RoomListSectionHeaderViewWrapperImpl = ({ onClick, onFocus, + editSection, + removeSection, isFocused, sectionIndex, sectionCount, @@ -32,7 +34,7 @@ const RoomListSectionHeaderViewWrapperImpl = ({ roomCountInSection, ...rest }: RoomListSectionHeaderProps): JSX.Element => { - const vm = useMockedViewModel(rest, { onClick }); + const vm = useMockedViewModel(rest, { onClick, editSection, removeSection }); return ( ; + /** Handler invoked when the edit section button is clicked */ + editSection: () => void; + /** Handler invoked when the remove section button is clicked */ + removeSection: () => void; } /** @@ -91,7 +100,7 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView roomCountInSection, }: Readonly): JSX.Element { const { translate: _t } = useI18n(); - const { id, title, isExpanded, isUnread } = useViewModel(vm); + const { id, title, isExpanded, isUnread, displaySectionMenu } = useViewModel(vm); const isLastSection = sectionIndex === sectionCount - 1; return ( @@ -118,11 +127,75 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView : _t("room_list|section_header|toggle", { section: title }) } > - - - {title} + + + + {title} + + {displaySectionMenu && } ); }); + +interface MenuComponentProps { + vm: RoomListSectionHeaderViewModel; +} + +/** + * + * Menu component for the section header. + */ + +function MenuComponent({ vm }: MenuComponentProps): JSX.Element { + const [open, setOpen] = useState(false); + + return ( + + + + } + > + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
          e.stopPropagation()} + > + vm.editSection()} + onClick={(evt) => evt.stopPropagation()} + /> + vm.removeSection()} + onClick={(evt) => evt.stopPropagation()} + /> +
          +
          + ); +} diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/__snapshots__/RoomListSectionHeaderView.test.tsx.snap b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/__snapshots__/RoomListSectionHeaderView.test.tsx.snap index 2cbf196de1..c2eb9d9708 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/__snapshots__/RoomListSectionHeaderView.test.tsx.snap +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/__snapshots__/RoomListSectionHeaderView.test.tsx.snap @@ -24,24 +24,63 @@ exports[` stories > renders Default story 1`] = ` >
          - - - - + + + + Favourites + +
          + diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.module.css b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.module.css index c444c8c1cd..e42f1fbaca 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.module.css +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.module.css @@ -9,6 +9,5 @@ * Room list container styles */ .roomList { - height: 100%; width: 100%; } diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx index a5048b5c73..b1c312cee0 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx @@ -34,6 +34,7 @@ const RoomListWrapperImpl = ({ getRoomItemViewModel, getSectionHeaderViewModel, updateVisibleRooms, + closeToast, renderAvatar: renderAvatarProp, ...rest }: RoomListStoryProps): JSX.Element => { @@ -44,6 +45,7 @@ const RoomListWrapperImpl = ({ getRoomItemViewModel, getSectionHeaderViewModel, updateVisibleRooms, + closeToast, }); return ( @@ -82,6 +84,7 @@ const meta = { updateVisibleRooms: fn(), renderAvatar, isFlatList: true, + closeToast: fn(), }, parameters: { design: { diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx index 2d5ff3ed34..f37302a129 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx @@ -13,7 +13,7 @@ import { describe, it, expect } from "vitest"; import * as stories from "./VirtualizedRoomListView.stories"; -const { Default } = composeStories(stories); +const { Default, Sections } = composeStories(stories); const renderWithMockContext = (component: React.ReactElement): ReturnType => { return render(component, { @@ -64,4 +64,28 @@ describe("", () => { renderWithMockContext(); expect(Default.args.updateVisibleRooms).toHaveBeenCalled(); }); + + describe("scrollToSectionTag", () => { + it("skips scroll when scrollToSectionTag does not match any section", () => { + const roomListState = { + activeRoomIndex: 0, + spaceId: "!space:server", + scrollToSectionTag: "nonexistent", + }; + renderWithMockContext(); + expect(screen.getByRole("treegrid", { name: "Room list" })).toBeInTheDocument(); + }); + + it("scrolls to the section when scrollToSectionTag matches", () => { + // sections: favourites(3 rooms), chats(1 room), low-priority(6 rooms) + // flat index for "chats" = 3 rooms + 1 header = 4 + const roomListState = { + activeRoomIndex: 0, + spaceId: "!space:server", + scrollToSectionTag: "chats", + }; + renderWithMockContext(); + expect(screen.getByRole("treegrid", { name: "Room list" })).toBeInTheDocument(); + }); + }); }); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx index ad6b105e05..2cf7ce76c7 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx @@ -5,8 +5,8 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { useCallback, useMemo, useRef, type JSX, type ReactNode } from "react"; -import { type ScrollIntoViewLocation } from "react-virtuoso"; +import React, { useCallback, useLayoutEffect, useMemo, useRef, type JSX, type ReactNode } from "react"; +import { type ScrollIntoViewLocation, type VirtuosoHandle } from "react-virtuoso"; import { isEqual } from "lodash"; import { type Room } from "./RoomListItemAccessibilityWrapper/RoomListItemView"; @@ -21,6 +21,7 @@ import type { RoomListViewSnapshot, RoomListViewModel } from "../RoomListView"; import { GroupedVirtualizedList } from "../../core/VirtualizedList"; import { RoomListSectionHeaderView } from "./RoomListSectionHeaderView"; import { RoomListItemAccessibilityWrapper } from "./RoomListItemAccessibilityWrapper"; +import styles from "./VirtualizedRoomListView.module.css"; /** * Filter key type - opaque string type for filter identifiers @@ -37,6 +38,8 @@ export interface RoomListViewState { spaceId?: string; /** Active filter keys for context tracking */ filterKeys?: FilterKey[]; + /** Tag of a newly created section header to scroll into view */ + scrollToSectionTag?: string; } /** @@ -109,8 +112,13 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual const snapshot = useViewModel(vm); const { roomListState, sections, isFlatList } = snapshot; const activeRoomIndex = roomListState.activeRoomIndex; + const scrollToSectionTag = roomListState.scrollToSectionTag; const lastSpaceId = useRef(undefined); const lastFilterKeys = useRef(undefined); + const virtuosoHandleRef = useRef(null); + const setVirtuosoHandle = useCallback((handle: VirtuosoHandle | null) => { + virtuosoHandleRef.current = handle; + }, []); const roomIds = useMemo(() => sections.flatMap((section) => section.roomIds), [sections]); const roomCount = roomIds.length; const sectionCount = sections.length; @@ -327,6 +335,16 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual [activeRoomIndex], ); + // Imperatively scroll to a newly created section header. + // scrollIntoView on virtuoso handle is more reliable in this case vs scrollIntoViewOnChange + useLayoutEffect(() => { + if (scrollToSectionTag === undefined) return; + const sectionIndex = sections.findIndex((s) => s.id === scrollToSectionTag); + if (sectionIndex === -1) return; + const flatIndex = sections.slice(0, sectionIndex).reduce((acc, s) => acc + s.roomIds.length + 1, 0); + virtuosoHandleRef.current?.scrollIntoView({ index: flatIndex, align: "start", behavior: "auto" }); + }, [scrollToSectionTag, sections]); + const isItemFocusable = useCallback(() => true, []); const isGroupHeaderFocusable = useCallback(() => true, []); const increaseViewportBy = useMemo( @@ -350,6 +368,7 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual rangeChanged, onKeyDown, increaseViewportBy, + className: styles.roomList, }; if (isFlatList) { @@ -367,6 +386,7 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual {...commonProps} {...getContainerAccessibleProps("treegrid", totalCount)} + scrollHandleRef={setVirtuosoHandle} groups={groups} getHeaderKey={getHeaderKey} getGroupHeaderComponent={getGroupHeaderComponent} diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/__snapshots__/VirtualizedRoomListView.test.tsx.snap b/packages/shared-components/src/room-list/VirtualizedRoomListView/__snapshots__/VirtualizedRoomListView.test.tsx.snap index 6b64e96ad3..03e092feff 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/__snapshots__/VirtualizedRoomListView.test.tsx.snap +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/__snapshots__/VirtualizedRoomListView.test.tsx.snap @@ -10,6 +10,7 @@ exports[` > renders Default story 1`] = ` >
          ): JSX onClick={termsAndConditionsClicked} className={styles.primaryAction} kind="primary" - size="sm" + size="md" as="a" href={snapshot.consentUri} target="_blank" @@ -208,7 +208,7 @@ export function RoomStatusBarView({ vm }: Readonly): JSX snapshot.adminContactHref && ( ), @@ -58,7 +59,7 @@ export const WithAction: Story = { export const WithAvatarImage: Story = { args: { - avatar: Example, + avatar: Example, }, }; diff --git a/packages/shared-components/src/room/composer/Banner/Banner.tsx b/packages/shared-components/src/room/composer/Banner/Banner.tsx index 1253c25fe1..96caf68cfd 100644 --- a/packages/shared-components/src/room/composer/Banner/Banner.tsx +++ b/packages/shared-components/src/room/composer/Banner/Banner.tsx @@ -82,7 +82,7 @@ export function Banner({
          {actions} {onClose && ( - )} diff --git a/packages/shared-components/src/room/composer/Banner/__snapshots__/Banner.test.tsx.snap b/packages/shared-components/src/room/composer/Banner/__snapshots__/Banner.test.tsx.snap index 01e8c45b9e..50b8cb4d07 100644 --- a/packages/shared-components/src/room/composer/Banner/__snapshots__/Banner.test.tsx.snap +++ b/packages/shared-components/src/room/composer/Banner/__snapshots__/Banner.test.tsx.snap @@ -46,18 +46,18 @@ exports[`AvatarWithDetails > renders a banner with an action 1`] = ` class="Banner-module_actions" >
          renders a banner with an avatar iamge 1`] = ` class="Banner-module_actions" > )} diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/__snapshots__/TileErrorView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/__snapshots__/TileErrorView.test.tsx.snap index 17775427bf..61ebdbcc07 100644 --- a/packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/__snapshots__/TileErrorView.test.tsx.snap +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/__snapshots__/TileErrorView.test.tsx.snap @@ -18,9 +18,9 @@ exports[`TileErrorView > renders the bubble layout variant 1`] = ` (m.room.message) +
          + ); + } else { + // Otherwise, the preview can be clicked on. + img = ( + + ); + } + } + + return ( +
          + {img} +
          + {preview.author && ( + + {preview.author} + + )} + + + {preview.description} + + {preview.siteName && } +
          +
          + ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/__snapshots__/LinkPreview.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/__snapshots__/LinkPreview.test.tsx.snap new file mode 100644 index 0000000000..833d60f581 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/__snapshots__/LinkPreview.test.tsx.snap @@ -0,0 +1,182 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`LinkPreview > renders a playable preview that can be opened with a click 1`] = ` +
          +
          + +
          + + A linked video + +

          + This is a link to a video. You cannot play the video inline yet, but you can click the play button to open the link +

          +
          + + blog.example.org + +
          +
          +
          +
          +`; + +exports[`LinkPreview > renders a preview 1`] = ` +
          +
          + +
          + + A simple title + +

          + A simple description +

          +
          + + Site name + +
          +
          +
          +
          +`; + +exports[`LinkPreview > renders a preview with just a title 1`] = ` +
          +
          +
          + + A simple title + +
          + + matrix.org + +
          +
          +
          +
          +`; + +exports[`LinkPreview > renders a preview with just a title and description 1`] = ` +
          +
          +
          + + A simple title + +

          + A simple description with a link to + + https://matrix.org + +

          +
          + + matrix.org + +
          +
          +
          +
          +`; diff --git a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/index.ts b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/index.ts similarity index 100% rename from packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/index.ts rename to packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/index.ts diff --git a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.module.css b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.module.css similarity index 100% rename from packages/shared-components/src/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.module.css rename to packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.module.css diff --git a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx similarity index 73% rename from packages/shared-components/src/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx rename to packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx index 60aa443176..b74e5c4cc3 100644 --- a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx +++ b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx @@ -8,17 +8,17 @@ import React, { type JSX } from "react"; import { fn } from "storybook/test"; -import imageFile from "../../../static/element.png"; -import tallImageFile from "../../../static/tallImage.png"; +import imageFile from "../../../../../static/element.png"; +import tallImageFile from "../../../../../static/tallImage.png"; import type { Meta, StoryFn } from "@storybook/react-vite"; import { UrlPreviewGroupView, type UrlPreviewGroupViewActions, type UrlPreviewGroupViewSnapshot, } from "./UrlPreviewGroupView"; -import { useMockedViewModel } from "../../core/viewmodel"; -import { LinkedTextContext } from "../../core/utils/LinkedText"; -import { withViewDocs } from "../../../.storybook/withViewDocs"; +import { useMockedViewModel } from "../../../../core/viewmodel"; +import { LinkedTextContext } from "../../../../core/utils/LinkedText"; +import { withViewDocs } from "../../../../../.storybook/withViewDocs"; type UrlPreviewGroupViewProps = UrlPreviewGroupViewSnapshot & UrlPreviewGroupViewActions; @@ -43,7 +43,7 @@ const UrlPreviewGroupViewWrapperImpl = ({ const UrlPreviewGroupViewWrapper = withViewDocs(UrlPreviewGroupViewWrapperImpl, UrlPreviewGroupView); export default { - title: "Event/UrlPreviewGroupView", + title: "Timeline/Timeline Event/UrlPreviewGroupView", component: UrlPreviewGroupViewWrapper, tags: ["autodocs"], args: { @@ -51,6 +51,12 @@ export default { onImageClick: fn(), onTogglePreviewLimit: fn(), }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/sI9A2kV2K4xeiyqJsL7Ey3/Link-Previews?node-id=87-7920", + }, + }, } satisfies Meta; const Template: StoryFn = (args) => ; @@ -62,9 +68,13 @@ Default.args = { title: "A simple title", description: "A simple description", link: "https://matrix.org", + showTooltipOnLink: false, + siteName: "matrix.org", image: { imageThumb: imageFile, imageFull: imageFile, + alt: "The element logo", + playable: false, }, }, ], @@ -72,17 +82,7 @@ Default.args = { export const MultiplePreviewsHidden = Template.bind({}); MultiplePreviewsHidden.args = { - previews: [ - { - title: "A simple title", - description: "A simple description", - link: "https://matrix.org", - image: { - imageThumb: imageFile, - imageFull: imageFile, - }, - }, - ], + previews: Default.args.previews, overPreviewLimit: true, previewsLimited: true, totalPreviewCount: 10, @@ -95,9 +95,13 @@ MultiplePreviewsVisible.args = { title: "One", description: "A regular square image.", link: "https://matrix.org", + siteName: "matrix.org", + showTooltipOnLink: false, image: { imageThumb: imageFile, imageFull: imageFile, + alt: "The element logo", + playable: false, }, }, // These images should appear the same size despite having different dimensions. @@ -105,18 +109,26 @@ MultiplePreviewsVisible.args = { title: "Two", description: "This one has a taller image which should crop nicely.", link: "https://matrix.org", + siteName: "matrix.org", + showTooltipOnLink: false, image: { imageThumb: tallImageFile, imageFull: tallImageFile, + alt: "A dog", + playable: false, }, }, { title: "Three", description: "One more description", link: "https://matrix.org", + siteName: "matrix.org", + showTooltipOnLink: false, image: { imageThumb: imageFile, imageFull: imageFile, + alt: "The element logo", + playable: false, }, }, ], diff --git a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.test.tsx b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.test.tsx similarity index 100% rename from packages/shared-components/src/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.test.tsx rename to packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.test.tsx diff --git a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.tsx b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.tsx similarity index 86% rename from packages/shared-components/src/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.tsx rename to packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.tsx index 8dae7298aa..79f0a96f93 100644 --- a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.tsx +++ b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.tsx @@ -10,8 +10,8 @@ import { Button, IconButton } from "@vector-im/compound-web"; import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close"; import classNames from "classnames"; -import { useViewModel, type ViewModel } from "../../core/viewmodel"; -import { useI18n } from "../../core/i18n/i18nContext"; +import { useViewModel, type ViewModel } from "../../../../core/viewmodel"; +import { useI18n } from "../../../../core/i18n/i18nContext"; import type { UrlPreview } from "./types"; import { LinkPreview } from "./LinkPreview"; import styles from "./UrlPreviewGroupView.module.css"; @@ -52,7 +52,7 @@ export function UrlPreviewGroupView({ vm }: UrlPreviewGroupViewProps): JSX.Eleme let toggleButton: JSX.Element | undefined; if (overPreviewLimit) { toggleButton = ( -
          - + diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/__snapshots__/UrlPreviewGroupView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/__snapshots__/UrlPreviewGroupView.test.tsx.snap new file mode 100644 index 0000000000..26f76e8e05 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/__snapshots__/UrlPreviewGroupView.test.tsx.snap @@ -0,0 +1,500 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`UrlPreviewGroupView > renders a single preview 1`] = ` +
          +
          +
          +
          + +
          + + A simple title + +

          + A simple description +

          +
          + + matrix.org + +
          +
          +
          +
          + +
          +
          +`; + +exports[`UrlPreviewGroupView > renders multiple previews 1`] = ` +
          +
          +
          +
          + +
          + + One + +

          + A regular square image. +

          +
          + + matrix.org + +
          +
          +
          +
          + +
          + + Two + +

          + This one has a taller image which should crop nicely. +

          +
          + + matrix.org + +
          +
          +
          +
          + +
          + + Three + +

          + One more description +

          +
          + + matrix.org + +
          +
          +
          + +
          + +
          +
          +`; + +exports[`UrlPreviewGroupView > renders multiple previews which are hidden 1`] = ` +
          +
          +
          +
          + +
          + + A simple title + +

          + A simple description +

          +
          + + matrix.org + +
          +
          +
          + +
          + +
          +
          +`; + +exports[`UrlPreviewGroupView > renders with a compact view 1`] = ` +
          +
          +
          +
          + +
          + + One + +

          + A regular square image. +

          +
          + + matrix.org + +
          +
          +
          +
          + +
          + + Two + +

          + This one has a taller image which should crop nicely. +

          +
          + + matrix.org + +
          +
          +
          +
          + +
          + + Three + +

          + One more description +

          +
          + + matrix.org + +
          +
          +
          + +
          + +
          +
          +`; diff --git a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/index.ts b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/index.ts similarity index 100% rename from packages/shared-components/src/event-tiles/UrlPreviewGroupView/index.ts rename to packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/index.ts diff --git a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/types.ts b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/types.ts similarity index 70% rename from packages/shared-components/src/event-tiles/UrlPreviewGroupView/types.ts rename to packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/types.ts index 4e9da4d4d6..8d26743987 100644 --- a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/types.ts +++ b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/types.ts @@ -5,7 +5,6 @@ * Please see LICENSE files in the repository root for full details. */ -/** Represents a URL preview. */ export interface UrlPreview { /** * The URL for the preview. @@ -14,7 +13,7 @@ export interface UrlPreview { /** * Should the link have a tooltip. Should be `true` if the platform does not provide a tooltip. */ - showTooltipOnLink?: boolean; + showTooltipOnLink: boolean; /** * The title of the page being previewed. */ @@ -22,7 +21,11 @@ export interface UrlPreview { /** * The site name to be displayed alongside the title. */ - siteName?: string; + siteName: string; + /** + * The HTTP URI of the the sites icon. + */ + siteIcon?: string; /** * Description of the site. May contain links. */ @@ -44,12 +47,26 @@ export interface UrlPreview { */ fileSize?: number; /** - * The width of the thumbnail. Must not exceed 100px. + * The width of the thumbnail. */ width?: number; /** - * The height of the thumbnail. Must not exceed 100px. + * The height of the thumbnail. */ height?: number; + /** + * Alt text for the image + */ + alt?: string; + + /** + * Is the media playable. + */ + playable: boolean; }; + + /** + * Author of the content, if specified. + */ + author?: string; } diff --git a/packages/shared-components/src/room/timeline/event-tile/actions/ActionBarView/ActionBarButton.tsx b/packages/shared-components/src/room/timeline/event-tile/actions/ActionBarView/ActionBarButton.tsx index f7bf79c3f2..44307ad56c 100644 --- a/packages/shared-components/src/room/timeline/event-tile/actions/ActionBarView/ActionBarButton.tsx +++ b/packages/shared-components/src/room/timeline/event-tile/actions/ActionBarView/ActionBarButton.tsx @@ -5,9 +5,11 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { type JSX } from "react"; +import React, { type JSX, useLayoutEffect, useRef } from "react"; +import { useMergeRefs } from "react-merge-refs"; import { Button, Tooltip } from "@vector-im/compound-web"; +import { useRovingTabIndex } from "../../../../../core/roving"; import styles from "./ActionBarView.module.css"; interface ActionBarButtonProps { @@ -36,6 +38,16 @@ export function ActionBarButton({ tooltipCaption, }: Readonly): JSX.Element { const iconOnly = presentation === "icon"; + const [onFocus, isActive, rovingRef] = useRovingTabIndex(); + const localRef = useRef(null); + const ref = useMergeRefs([buttonRef, localRef, disabled ? null : rovingRef]); + const tabIndex = disabled || !isActive ? -1 : 0; + + useLayoutEffect(() => { + if (!localRef.current) return; + + localRef.current.tabIndex = tabIndex; + }, [tabIndex]); const handleContextMenu = (event: React.MouseEvent): void => { event.preventDefault(); @@ -47,9 +59,9 @@ export function ActionBarButton({ @@ -257,7 +257,7 @@ export function FileBodyView({ vm, refIFrame, refLink, className }: Readonly + + + ) : resolvedImageSrc ? ( + {alt} setHover(true)} + onMouseLeave={(): void => setHover(false)} + /> + ) : null; + + const banner = + state === ImageBodyViewState.READY && bannerLabel && hoverOrFocus ? ( + {bannerLabel} + ) : null; + + const gifBadge = + state === ImageBodyViewState.READY && gifLabel && !hoverOrFocus ? ( +

          {gifLabel}

          + ) : null; + + let frame = ( +
          + {showPlaceholder && ( +
          + {placeholderNode} +
          + )} + +
          + {media} + {gifBadge} + {banner} +
          +
          + ); + + if (tooltipLabel) { + frame = ( + + {frame} + + ); + } + + if (state === ImageBodyViewState.READY && linkUrl) { + frame = ( +
          setFocus(true)} + onBlur={(): void => setFocus(false)} + > + {frame} + + ); + } + + return ( +
          + {frame} + {children} +
          + ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/__snapshots__/ImageBodyView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/__snapshots__/ImageBodyView.test.tsx.snap new file mode 100644 index 0000000000..1c047b6a3a --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/__snapshots__/ImageBodyView.test.tsx.snap @@ -0,0 +1,238 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ImageBodyView > matches snapshot for animated-preview story 1`] = ` +
          +
          + +
          +
          + Element logo +

          + GIF +

          +
          +
          +
          +
          + File body slot +
          +
          +
          +`; + +exports[`ImageBodyView > matches snapshot for default story 1`] = ` +
          +
          + +
          +
          + Element logo +
          +
          +
          +
          + File body slot +
          +
          +
          +`; + +exports[`ImageBodyView > matches snapshot for error story 1`] = ` +
          + + + + + + Unable to show image due to error + +
          +`; + +exports[`ImageBodyView > matches snapshot for hidden story 1`] = ` +
          +
          +
          +
          +
          + +
          +
          +
          +
          + File body slot +
          +
          +
          +`; + +exports[`ImageBodyView > matches snapshot for loading-with-blurhash story 1`] = ` +
          +
          + +
          +
          +
          + +
          +
          +
          + Element logo +
          +
          +
          +
          + File body slot +
          +
          +
          +`; + +exports[`ImageBodyView > matches snapshot for loading-with-spinner story 1`] = ` +
          +
          + +
          +
          + + + +
          +
          + Element logo +
          +
          +
          +
          + File body slot +
          +
          +
          +`; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/index.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/index.tsx new file mode 100644 index 0000000000..a71a80c047 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/index.tsx @@ -0,0 +1,15 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { + ImageBodyView, + ImageBodyViewPlaceholder, + ImageBodyViewState, + type ImageBodyViewActions, + type ImageBodyViewModel, + type ImageBodyViewSnapshot, +} from "./ImageBodyView"; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MVideoBodyView/VideoBodyView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MVideoBodyView/VideoBodyView.stories.tsx index 115d93e5ca..3b7d60371c 100644 --- a/packages/shared-components/src/room/timeline/event-tile/body/MVideoBodyView/VideoBodyView.stories.tsx +++ b/packages/shared-components/src/room/timeline/event-tile/body/MVideoBodyView/VideoBodyView.stories.tsx @@ -17,8 +17,7 @@ import { } from "./VideoBodyView"; import { useMockedViewModel } from "../../../../../core/viewmodel/useMockedViewModel"; import { withViewDocs } from "../../../../../../.storybook/withViewDocs"; - -const demoVideo = new URL("../../../../../../static/videoBodyDemo.webm", import.meta.url).href; +import demoVideo from "../../../../../../static/videoBodyDemo.webm"; type VideoBodyViewProps = VideoBodyViewSnapshot & VideoBodyViewActions & { @@ -45,7 +44,7 @@ const VideoBodyViewWrapperImpl = ({ const VideoBodyViewWrapper = withViewDocs(VideoBodyViewWrapperImpl, VideoBodyView); const meta = { - title: "MessageBody/VideoBodyView", + title: "Timeline/Timeline Body/VideoBodyView", component: VideoBodyViewWrapper, tags: ["autodocs"], argTypes: { diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MVideoBodyView/__snapshots__/VideoBodyView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/body/MVideoBodyView/__snapshots__/VideoBodyView.test.tsx.snap index 57c2fa47d1..f811158277 100644 --- a/packages/shared-components/src/room/timeline/event-tile/body/MVideoBodyView/__snapshots__/VideoBodyView.test.tsx.snap +++ b/packages/shared-components/src/room/timeline/event-tile/body/MVideoBodyView/__snapshots__/VideoBodyView.test.tsx.snap @@ -79,7 +79,7 @@ exports[`VideoBodyView > matches snapshot for ready story 1`] = ` crossorigin="anonymous" poster="/static/element.png" preload="none" - src="http://localhost:63315/static/videoBodyDemo.webm" + src="/static/videoBodyDemo.webm" />
          diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MediaBody/MediaBody.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MediaBody/MediaBody.stories.tsx index bc41f88754..6ee675f565 100644 --- a/packages/shared-components/src/room/timeline/event-tile/body/MediaBody/MediaBody.stories.tsx +++ b/packages/shared-components/src/room/timeline/event-tile/body/MediaBody/MediaBody.stories.tsx @@ -9,7 +9,7 @@ import { MediaBody } from "./MediaBody"; import type { Meta, StoryObj } from "@storybook/react-vite"; const meta = { - title: "MessageBody/MediaBody", + title: "Timeline/Timeline Body/MediaBody", component: MediaBody, tags: ["autodocs"], args: { diff --git a/packages/shared-components/src/room/timeline/event-tile/body/RedactedBodyView/RedactedBodyView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/body/RedactedBodyView/RedactedBodyView.stories.tsx index 5dda5f50f6..c1b2794fae 100644 --- a/packages/shared-components/src/room/timeline/event-tile/body/RedactedBodyView/RedactedBodyView.stories.tsx +++ b/packages/shared-components/src/room/timeline/event-tile/body/RedactedBodyView/RedactedBodyView.stories.tsx @@ -27,7 +27,7 @@ const RedactedBodyViewWrapperImpl = ({ const RedactedBodyViewWrapper = withViewDocs(RedactedBodyViewWrapperImpl, RedactedBodyView); const meta = { - title: "MessageBody/RedactedBodyView", + title: "Timeline/Timeline Body/RedactedBodyView", component: RedactedBodyViewWrapper, tags: ["autodocs"], args: { diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.module.css b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.module.css index 683c8a8a2a..fe64ec04a4 100644 --- a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.module.css +++ b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.module.css @@ -37,7 +37,7 @@ user-select: none; display: inline-block; margin-inline-start: 9px; /* Preserve legacy EventTile spacing for inline annotations like (edited) */ - font: var(--cpd-font-body-xs-regular); + font-size: 12px; /* Match the legacy EventTile edited-marker size */ color: var(--cpd-color-text-secondary); } @@ -45,8 +45,12 @@ appearance: none; background: none; border: none; + height: max-content; padding: 0; cursor: pointer; + font-family: inherit; + font-weight: inherit; + line-height: inherit; } .bodyLink, @@ -66,10 +70,19 @@ } .emoteSender { - all: unset; - font: inherit; + appearance: none; + background: none; + border: none; + padding: 0; color: inherit; cursor: pointer; + font-family: inherit; + font-size: inherit; + font-style: inherit; + font-weight: inherit; + letter-spacing: inherit; + line-height: inherit; + text-align: inherit; } .editedMarker:focus-visible, diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx index 26604c0814..c15d9abf76 100644 --- a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx +++ b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx @@ -76,7 +76,7 @@ const TEXTUAL_BODY_VIEW_BODY_WRAPPER_KIND_OPTIONS = [ ]; const meta = { - title: "MessageBody/TextualBody", + title: "Timeline/Timeline Body/TextualBody", component: TextualBodyViewWrapper, tags: ["autodocs"], argTypes: { diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.test.tsx b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.test.tsx index 5e4d8a3e89..e004d74a66 100644 --- a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.test.tsx +++ b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.test.tsx @@ -74,7 +74,7 @@ describe("TextualBodyView", () => { implements TextualBodyViewActions { public onEditedMarkerClick?: MouseEventHandler; - public onBodyActionClick?: MouseEventHandler; + public onBodyActionClick?: MouseEventHandler; public onEmoteSenderClick?: MouseEventHandler; public constructor(snapshot: TextualBodyViewSnapshot, actions: TextualBodyViewActions) { @@ -160,7 +160,7 @@ describe("TextualBodyView", () => { extends MockViewModel implements TextualBodyViewActions { - public onBodyActionClick?: MouseEventHandler; + public onBodyActionClick?: MouseEventHandler; public constructor(snapshot: TextualBodyViewSnapshot, actions: TextualBodyViewActions) { super(snapshot); diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBodyView.tsx b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBodyView.tsx index 340bed6bd8..e4d0c022aa 100644 --- a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBodyView.tsx +++ b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBodyView.tsx @@ -62,6 +62,10 @@ export interface TextualBodyViewSnapshot { * Visible label for the edited marker. */ editedMarkerText?: string; + /** + * Accessible label announced for the edited marker action. + */ + editedMarkerAriaLabel?: string; /** * Tooltip description for the edited marker. */ @@ -92,7 +96,7 @@ export interface TextualBodyViewActions { /** * Activation handler used when `bodyWrapper` is `ACTION`. */ - onBodyActionClick?: MouseEventHandler; + onBodyActionClick?: MouseEventHandler; /** * Click handler for the edited marker. */ @@ -165,6 +169,7 @@ export function TextualBodyView({ bodyActionAriaLabel, showEditedMarker, editedMarkerText, + editedMarkerAriaLabel, editedMarkerTooltip, editedMarkerCaption, showPendingModerationMarker, @@ -195,6 +200,8 @@ export function TextualBodyView({ type="button" className={classNames(styles.annotation, styles.editedMarker)} onClick={onEditedMarkerClick} + aria-label={editedMarkerAriaLabel} + data-textual-body-edited-marker="" > {editedMarkerText} @@ -218,7 +225,7 @@ export function TextualBodyView({ if (showPendingModerationMarker) { markers.push( - + {pendingModerationText} , ); diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/__snapshots__/TextualBody.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/__snapshots__/TextualBody.test.tsx.snap index 79201640bc..e33064185f 100644 --- a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/__snapshots__/TextualBody.test.tsx.snap +++ b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/__snapshots__/TextualBody.test.tsx.snap @@ -40,6 +40,7 @@ exports[`TextualBodyView > renders emote messages with annotations 1`] = `
    {_t("devtools|crypto|session")}
    {_t("devtools|crypto|device_id")}