From 35484e518332eaabe854bfa709d871965621718c Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 1 Jun 2026 13:26:03 +0100 Subject: [PATCH 1/8] Switch to using diff-cover for the Coverage gate (#33673) * Switch to using diff-cover for the Coverage gate Enabling developers to check their PR coverage without waiting for CI * Remove sonar-project.properties - in automatic mode Sonar will not read this file * Add note to CONTRIBUTING.md * Skip sonar in MQ --- .github/workflows/sonarqube.yml | 27 -------- .github/workflows/tests.yml | 82 +++++++++++++++++-------- CONTRIBUTING.md | 4 ++ apps/desktop/package.json | 5 +- apps/web/jest.config.ts | 1 - apps/web/package.json | 7 +-- diff-cover.toml | 2 + knip.ts | 6 +- package.json | 3 +- packages/module-api/package.json | 7 ++- packages/shared-components/package.json | 5 +- packages/vite-common/package.json | 3 +- packages/vite-common/vite.config.ts | 7 --- pnpm-lock.yaml | 49 --------------- pnpm-workspace.yaml | 1 - sonar-project.properties | 68 -------------------- 16 files changed, 83 insertions(+), 194 deletions(-) delete mode 100644 .github/workflows/sonarqube.yml create mode 100644 diff-cover.toml delete mode 100644 sonar-project.properties diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml deleted file mode 100644 index e934f05ad12..00000000000 --- a/.github/workflows/sonarqube.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: SonarQube -on: - # Privilege escalation necessary to call upon SonarCloud - # 🚨 We must not execute any checked out code here. - workflow_run: # zizmor: ignore[dangerous-triggers] - workflows: ["Tests"] - types: - - completed -concurrency: - group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }} - cancel-in-progress: true -permissions: {} -jobs: - sonarqube: - name: 🩻 SonarQube - if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group' - uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop # zizmor: ignore[unpinned-uses] - permissions: - actions: read - statuses: write - id-token: write # sonar - secrets: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} - with: - sharded: true - version-pkg-json-dir: ./apps/web diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9e60b9b01f2..dedf36333cb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -80,8 +80,6 @@ jobs: --shard "$SHARD" \ --cacheDirectory /tmp/jest_cache env: - JEST_SONAR_UNIQUE_OUTPUT_NAME: true - # tell jest to use coloured output FORCE_COLOR: true MAX_WORKERS: ${{ steps.cpu-cores.outputs.count }} @@ -103,28 +101,6 @@ jobs: apps/web/coverage !apps/web/coverage/lcov-report - complete: - name: jest-tests - needs: [jest_ew, vitest] - if: always() - runs-on: ubuntu-24.04 - permissions: - statuses: write - steps: - - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') - run: exit 1 - - - name: Skip SonarCloud in merge queue - if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true' - uses: guibranco/github-status-action-v2@9bfa8773cdbdc6c185747fd43cd7faa9d7c32f09 - with: - authToken: ${{ secrets.GITHUB_TOKEN }} - state: success - description: SonarCloud skipped - context: SonarCloud Code Analysis - sha: ${{ github.sha }} - target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - vitest: name: Vitest strategy: @@ -191,3 +167,61 @@ jobs: path: | ${{ matrix.path }}/coverage !${{ matrix.path }}/coverage/lcov-report + + complete: + name: Tests + needs: + - jest_ew + - vitest + if: always() + runs-on: ubuntu-24.04 + permissions: + contents: read + pull-requests: read + checks: write + statuses: write + steps: + - name: Skip SonarCloud in merge queue + if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true' + uses: guibranco/github-status-action-v2@9bfa8773cdbdc6c185747fd43cd7faa9d7c32f09 + with: + authToken: ${{ secrets.GITHUB_TOKEN }} + state: success + description: SonarCloud skipped + context: SonarCloud Code Analysis + sha: ${{ github.sha }} + target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + if: needs.test.result == 'success' && env.ENABLE_COVERAGE == 'true' + with: + persist-credentials: false + fetch-depth: 0 # Full history, fastest for diff-cover + + - name: Download coverage artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + if: needs.test.result == 'success' && env.ENABLE_COVERAGE == 'true' + with: + pattern: coverage-* + path: coverage + + - name: Diff Coverage + id: coverage + if: needs.test.result == 'success' && env.ENABLE_COVERAGE == 'true' + uses: Affanmir/diff-cover-action@0d8c98f613bbd2428df50b3109b1e3b1d5ab59d3 # v2.1.0 + with: + compare-branch: origin/${{ github.base_ref || 'develop' }} + mode: coverage + coverage-files: coverage/*/*lcov.info + ignore-whitespace: true + show-uncovered: true + post-comment: false + create-annotations: true + annotation-type: warning + fail-on-threshold: ${{ contains(github.event.pull_request.labels.*.name, 'Z-Skip-Coverage') && 'false' || 'true' }} + fail-under: 80 + + - name: Check status of tests + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: exit 1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 615e4969385..8e5b3de55ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,6 +140,10 @@ These are located in `/spec/` in `matrix-js-sdk` or `/test/` in `element-web`. When writing unit tests, please aim for a high level of test coverage for new code - 80% or greater. If you cannot achieve that, please document why it's not possible in your PR. +CI will validate that the coverage reached on your change is sufficient, +you can also assert this locally by installing https://github.com/Bachmann1234/diff_cover, +running the entire test suite in coverage mode `pnpm coverage` then running +`pnpm coverage:diff` to see the coverage of the diff between your HEAD and `develop`. Some sections of code are not sensible to add coverage for, such as those which explicitly inhibit noisy logging for tests. Which can be hidden using diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4665edd2a1a..df8723051e4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -54,6 +54,8 @@ "test:playwright": "nx test:playwright --", "test:playwright:open": "nx test:playwright -- --ui", "test:playwright:screenshots": "nx test:playwright:screenshots --", + "coverage": "pnpm test:unit --coverage", + "coverage:diff": "diff-cover --config-file ../../diff-cover.toml coverage/lcov.info", "sane-postinstall": "electron-builder install-app-deps" }, "dependencies": { @@ -106,8 +108,7 @@ "rimraf": "^6.0.0", "tar": "^7.5.8", "typescript": "6.0.3", - "vitest": "catalog:", - "vitest-sonar-reporter": "catalog:" + "vitest": "catalog:" }, "hakDependencies": { "matrix-seshat": "4.3.0" diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index 7139bd2f342..75f72bfd077 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -68,7 +68,6 @@ if (env["GITHUB_ACTIONS"] !== undefined) { config.reporters ??= []; config.reporters.push(["github-actions", { silent: false }]); config.reporters.push("summary"); - config.reporters.push("@casualbot/jest-sonar-reporter"); // if we're running against the develop branch, also enable the slow test reporter if (env["GITHUB_REF"] == "refs/heads/develop") { diff --git a/apps/web/package.json b/apps/web/package.json index f19096f20bd..cf6ab7af4b0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,6 +34,7 @@ "test:playwright:open": "nx test:playwright -- --ui", "test:playwright:screenshots": "nx test:playwright:screenshots --", "coverage": "pnpm test --coverage", + "coverage:diff": "diff-cover --config-file ../../diff-cover.toml coverage/lcov.info", "analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp" }, "dependencies": { @@ -125,7 +126,6 @@ "@babel/preset-env": "^7.12.11", "@babel/preset-react": "^7.12.10", "@babel/preset-typescript": "^7.12.7", - "@casualbot/jest-sonar-reporter": "2.7.1", "@element-hq/element-call-embedded": "0.19.4", "@element-hq/element-web-playwright-common": "workspace:*", "@fetch-mock/jest": "^0.2.20", @@ -236,11 +236,6 @@ "webpack-version-file-plugin": "^0.5.0", "yaml": "^2.3.3" }, - "@casualbot/jest-sonar-reporter": { - "outputDirectory": "coverage", - "outputName": "jest-sonar-report.xml", - "relativePaths": true - }, "engines": { "node": ">=22.18" }, diff --git a/diff-cover.toml b/diff-cover.toml new file mode 100644 index 00000000000..fcb7783d75e --- /dev/null +++ b/diff-cover.toml @@ -0,0 +1,2 @@ +[tool.diff_cover] +compare_branch = "origin/develop" diff --git a/knip.ts b/knip.ts index 9e890e74947..81d6bd8771e 100644 --- a/knip.ts +++ b/knip.ts @@ -1,6 +1,6 @@ import { KnipConfig } from "knip"; -// Specify this as knip loads config files which may conditionally add reporters, e.g. `@casualbot/jest-sonar-reporter' +// Specify this as knip loads config files which may conditionally load plugins process.env.GITHUB_ACTIONS = "1"; export default { @@ -67,6 +67,10 @@ export default { "events", ], ignoreExportsUsedInFile: true, + ignoreBinaries: [ + // Optional for coverage:diff development script + "diff-cover", + ], compilers: { pcss: (text: string) => [...text.matchAll(/@import\s+(?:url\()?["']([^"']+)["']\)?[^;]*;/g)] diff --git a/package.json b/package.json index 291ccb287de..968c5d0d156 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "postinstall": "node scripts/pnpm-link.ts && pnpm run -r sane-postinstall", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", - "docs:preview": "vitepress preview docs" + "docs:preview": "vitepress preview docs", + "coverage:diff": "diff-cover --config-file diff-cover.toml apps/*/coverage/lcov.info packages/*/coverage/lcov.info" }, "devDependencies": { "@action-validator/cli": "^0.6.0", diff --git a/packages/module-api/package.json b/packages/module-api/package.json index 344ce5bfff1..c43ac4fb3a2 100644 --- a/packages/module-api/package.json +++ b/packages/module-api/package.json @@ -27,7 +27,9 @@ "scripts": { "prepack": "nx build", "lint:types": "nx lint:types", - "test:unit": "vitest" + "test:unit": "vitest", + "coverage": "pnpm test:unit --coverage", + "coverage:diff": "diff-cover --config-file ../../diff-cover.toml coverage/lcov.info" }, "devDependencies": { "@element-hq/vite-common": "workspace:*", @@ -44,8 +46,7 @@ "typescript": "^6.0.0", "unplugin-dts": "1.0.1", "vite": "catalog:", - "vitest": "catalog:", - "vitest-sonar-reporter": "catalog:" + "vitest": "catalog:" }, "peerDependencies": { "@matrix-org/react-sdk-module-api": "*", diff --git a/packages/shared-components/package.json b/packages/shared-components/package.json index 38e2fec1fd8..a67845feae0 100644 --- a/packages/shared-components/package.json +++ b/packages/shared-components/package.json @@ -51,6 +51,8 @@ "test:unit": "nx test:unit", "test:storybook": "nx test:storybook", "test:storybook:update": "nx test:storybook:update", + "coverage": "pnpm test:unit --coverage", + "coverage:diff": "diff-cover --config-file ../../diff-cover.toml coverage/lcov.info", "build": "nx build", "prepack": "pnpm run build", "storybook": "storybook dev -p 6007", @@ -132,8 +134,7 @@ "unplugin-dts": "1.0.1", "vite": "catalog:", "vite-plugin-node-polyfills": "^0.28.0", - "vitest": "catalog:", - "vitest-sonar-reporter": "catalog:" + "vitest": "catalog:" }, "engines": { "node": ">=20.0.0" diff --git a/packages/vite-common/package.json b/packages/vite-common/package.json index 4a335ac0e72..c36fd6683af 100644 --- a/packages/vite-common/package.json +++ b/packages/vite-common/package.json @@ -13,7 +13,6 @@ "typescript": "catalog:" }, "peerDependencies": { - "@vitest/coverage-v8": "catalog:", - "vitest-sonar-reporter": "catalog:" + "@vitest/coverage-v8": "catalog:" } } diff --git a/packages/vite-common/vite.config.ts b/packages/vite-common/vite.config.ts index 93cb0b90f13..fc7d7152675 100644 --- a/packages/vite-common/vite.config.ts +++ b/packages/vite-common/vite.config.ts @@ -31,13 +31,6 @@ const slowTestReporter: Reporter = { // if we're running under GHA, enable the GHA & Sonar reporters if (env["GITHUB_ACTIONS"] !== undefined) { reporters.push(["github-actions", { silent: false }]); - reporters.push([ - "vitest-sonar-reporter", - { - outputFile: "coverage/sonar-report.xml", - onWritePath: (path): string => `${process.cwd()}/${path}`, - }, - ]); // if we're running against the develop branch, also enable the slow test reporter if (env["GITHUB_REF"] == "refs/heads/develop") { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfcca418b81..c099c4f326b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -241,9 +241,6 @@ catalogs: vitest: specifier: 4.1.7 version: 4.1.7 - vitest-sonar-reporter: - specifier: 3.0.0 - version: 3.0.0 overrides: pretty-format@30>react-is: 19.2.6 @@ -496,9 +493,6 @@ importers: vitest: specifier: 'catalog:' version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(yaml@2.8.4)) - vitest-sonar-reporter: - specifier: 'catalog:' - version: 3.0.0(vitest@4.1.7) apps/web: dependencies: @@ -761,9 +755,6 @@ importers: '@babel/preset-typescript': specifier: ^7.12.7 version: 7.28.5(@babel/core@7.29.0) - '@casualbot/jest-sonar-reporter': - specifier: 2.7.1 - version: 2.7.1 '@element-hq/element-call-embedded': specifier: 0.19.4 version: 0.19.4 @@ -1146,9 +1137,6 @@ importers: vitest: specifier: 'catalog:' version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(yaml@2.8.4)) - vitest-sonar-reporter: - specifier: 'catalog:' - version: 3.0.0(vitest@4.1.7) packages/playwright-common: dependencies: @@ -1412,9 +1400,6 @@ importers: vitest: specifier: 'catalog:' version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(yaml@2.8.4)) - vitest-sonar-reporter: - specifier: 'catalog:' - version: 3.0.0(vitest@4.1.7) packages/vite-common: dependencies: @@ -1424,9 +1409,6 @@ importers: vitest: specifier: 'catalog:' version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(yaml@2.8.4)) - vitest-sonar-reporter: - specifier: 'catalog:' - version: 3.0.0(vitest@4.1.7) devDependencies: typescript: specifier: 'catalog:' @@ -2254,10 +2236,6 @@ packages: '@cacheable/utils@2.4.1': resolution: {integrity: sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==} - '@casualbot/jest-sonar-reporter@2.7.1': - resolution: {integrity: sha512-C/lzwYEXnHueUufmvSLRKSz8ANVCrFv7HjtGsLoPDUYwugn6CYB1E7gEiCUlWaDeq+DUib2xMUX0nMYpwxUYzA==} - engines: {node: '>=14.0.0'} - '@chevrotain/types@11.1.2': resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} @@ -10544,11 +10522,6 @@ packages: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true - mkdirp@1.0.4: - resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} - engines: {node: '>=10'} - hasBin: true - mkdirp@3.0.1: resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} engines: {node: '>=10'} @@ -13447,12 +13420,6 @@ packages: '@vitest/browser-webdriverio': optional: true - vitest-sonar-reporter@3.0.0: - resolution: {integrity: sha512-QRT5m9Z/3Kt0WVDVcFHm5LC9Ba89yDP4twlX2QUAJ9PdqfJBkLAh/kXj7m6U3i0k6GxnIEiwCc295bmAYokmZg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - peerDependencies: - vitest: '>=3' - vitest@4.1.7: resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -13763,9 +13730,6 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} - xml@1.0.1: - resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} - xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} @@ -14916,11 +14880,6 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 - '@casualbot/jest-sonar-reporter@2.7.1': - dependencies: - mkdirp: 1.0.4 - xml: 1.0.1 - '@chevrotain/types@11.1.2': {} '@csstools/cascade-layer-name-parser@3.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -24270,8 +24229,6 @@ snapshots: dependencies: minimist: 1.2.8 - mkdirp@1.0.4: {} - mkdirp@3.0.1: {} mlly@1.8.2: @@ -27835,10 +27792,6 @@ snapshots: - babel-plugin-macros - typescript - vitest-sonar-reporter@3.0.0(vitest@4.1.7): - dependencies: - vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(yaml@2.8.4)) - vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.7 @@ -28275,8 +28228,6 @@ snapshots: xml-name-validator@5.0.0: {} - xml@1.0.1: {} - xmlbuilder@15.1.1: {} xmlchars@2.2.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 00ca79adfca..85228dd6448 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -26,7 +26,6 @@ catalog: # vite vite: 8.0.13 vitest: 4.1.7 - vitest-sonar-reporter: 3.0.0 "@vitest/coverage-v8": 4.1.7 packageExtensions: diff --git a/sonar-project.properties b/sonar-project.properties deleted file mode 100644 index 7a25cf4571c..00000000000 --- a/sonar-project.properties +++ /dev/null @@ -1,68 +0,0 @@ -sonar.projectKey=element-web -sonar.organization=element-hq - -# Encoding of the source code. Default is default system encoding -#sonar.sourceEncoding=UTF-8 - -sonar.sources=. -sonar.tests=apps/web/test,apps/web/playwright,apps/desktop,packages -sonar.test.inclusions=\ - apps/web/test/*,\ - apps/web/playwright/*,\ - apps/desktop/playwright/*,\ - apps/desktop/src/**/*.test.ts,\ - packages/*/src/**/*.test.*,\ - packages/*/src/test/**/* -sonar.exclusions=\ - apps/web/__mocks__,\ - docs,\ - apps/web/element.io,\ - apps/web/nginx,\ - apps/web/src/vector/modernizr.cjs,\ - **/*.webm,\ - **/*.ogg,\ - **/*.mp3,\ - **/*.woff2,\ - **/*.ttf,\ - **/*.webp,\ - **/*.jpg,\ - **/*.apng,\ - **/*.ico,\ - **/*.png,\ - **/*.gif - -# Exclude translations and tests from the duplication check -sonar.cpd.exclusions=\ - **/src/i18n/strings/*.json,\ - apps/**/test/**,\ - apps/**/playwright/**,\ - packages/**/test/**,\ - packages/shared-components/src/**/*.stories.tsx,\ - packages/playwright-common/** - -sonar.javascript.lcov.reportPaths=apps/web/coverage/lcov.info,packages/shared-components/coverage/lcov.info -sonar.coverage.exclusions=\ - apps/web/test/**/*,\ - apps/web/playwright/**/*,\ - apps/web/res/**/*,\ - apps/web/scripts/**/*,\ - apps/web/__mocks__/**/*,\ - apps/web/I18nWebpackPlugin.ts,\ - apps/web/recorder-worklet-loader.cjs,\ - apps/web/src/components/views/dialogs/devtools/**/*,\ - apps/web/src/utils/SessionLock.ts,\ - apps/web/src/**/*.d.ts,\ - apps/web/src/vector/mobile_guide/**/*,\ - apps/desktop/electron-builder.ts,\ - apps/desktop/scripts/**/*,\ - apps/desktop/playwright/**/*,\ - apps/desktop/hak/**/*,\ - packages/shared-components/src/test/**/*,\ - packages/shared-components/src/**/*.stories.tsx,\ - packages/shared-components/scripts/**/*,\ - packages/playwright-common/**/*,\ - scripts/**/*,\ - docs/**/*,\ - **/*.config.*,\ - knip.ts -sonar.testExecutionReportPaths=apps/web/coverage/jest-sonar-report.xml From 9c4397fe9eb29f335bb0c670fbeb18deed269716 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 1 Jun 2026 14:17:53 +0100 Subject: [PATCH 2/8] Fix deployment to Cloudflare pages (#33674) Broken by pnpm 11 PR --- .github/workflows/build_develop.yml | 7 +++---- .github/workflows/deploy.yml | 6 ++---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build_develop.yml b/.github/workflows/build_develop.yml index 087db24a7f4..bbc1d28bd1b 100644 --- a/.github/workflows/build_develop.yml +++ b/.github/workflows/build_develop.yml @@ -127,15 +127,14 @@ jobs: - name: Deploy to Cloudflare Pages id: cfp - uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca # v1 + uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 with: apiToken: ${{ secrets.CF_PAGES_TOKEN }} accountId: ${{ secrets.CF_PAGES_ACCOUNT_ID }} - projectName: element-web-develop - directory: _deploy + command: pages deploy _deploy --project-name element-web-develop gitHubToken: ${{ secrets.GITHUB_TOKEN }} - run: | echo "Deployed to ${STEPS_CFP_OUTPUTS_URL}" >> $GITHUB_STEP_SUMMARY env: - STEPS_CFP_OUTPUTS_URL: ${{ steps.cfp.outputs.url }} + STEPS_CFP_OUTPUTS_URL: ${{ steps.cfp.outputs.deployment-url }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 238b881286e..162c67e2ab7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -91,11 +91,9 @@ jobs: check-regexp: ^((?!SonarCloud|SonarQube|issue|board|label|Release|prepare|GitHub Pages).)*$ - name: Deploy to Cloudflare Pages - uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca # v1 + uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 with: apiToken: ${{ secrets.CF_PAGES_TOKEN }} accountId: ${{ secrets.CF_PAGES_ACCOUNT_ID }} - projectName: ${{ env.SITE == 'staging.element.io' && 'element-web-staging' || 'element-web' }} - directory: _deploy + command: pages deploy _deploy --project-name ${{ env.SITE == 'staging.element.io' && 'element-web-staging' || 'element-web' }} --branch main gitHubToken: ${{ secrets.GITHUB_TOKEN }} - branch: main From bb24352bce1adb0054bb1b2804642b81ba4454e4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:18:59 +0000 Subject: [PATCH 3/8] Update testcontainers docker digests (#33683) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/web/playwright/testcontainers/mas.ts | 2 +- apps/web/playwright/testcontainers/synapse.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/playwright/testcontainers/mas.ts b/apps/web/playwright/testcontainers/mas.ts index 28b55e18470..b4446c371c2 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:07510c0e76c174c54509c7a54109ff950a82b9b3fc085c63d989f66c7f542285"; + "ghcr.io/element-hq/matrix-authentication-service:main@sha256:be4a4844608ab44a17d1c625dfbc3b8e5d4d11b4353ecee7498bf9ed6fd2b608"; /** * MatrixAuthenticationServiceContainer which freezes the docker digest to diff --git a/apps/web/playwright/testcontainers/synapse.ts b/apps/web/playwright/testcontainers/synapse.ts index 0260b0a4316..d81dc034a78 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:9be022fcbee02b3e5d526a25bf73b787c86348c0428ecac482b95ad7424a156e"; + "ghcr.io/element-hq/synapse:develop@sha256:be8a99ec2e06494fee6525152778bf263cd71a364c138a641cb0d3ee4a735d6b"; /** * SynapseContainer which freezes the docker digest to stabilise tests, From ffd524d8c2f35138560270e562b305001abed00a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:25:52 +0000 Subject: [PATCH 4/8] Update aws-actions/configure-aws-credentials digest to 99214aa (#33682) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build_desktop_and_deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_desktop_and_deploy.yaml b/.github/workflows/build_desktop_and_deploy.yaml index 1bddf5f635f..e7cd8494328 100644 --- a/.github/workflows/build_desktop_and_deploy.yaml +++ b/.github/workflows/build_desktop_and_deploy.yaml @@ -289,7 +289,7 @@ jobs: id-token: write # This is required for requesting the JWT steps: - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6 + uses: aws-actions/configure-aws-credentials@99214aa6889fcddfa57764031d71add364327e59 # v6 with: role-to-assume: arn:aws:iam::264135176173:role/Push-ElementDesktop-MSI role-session-name: githubaction-run-${{ github.run_id }} From 531fc767268423e68984e42e17bba157da854038 Mon Sep 17 00:00:00 2001 From: rbondesson Date: Mon, 1 Jun 2026 15:36:37 +0200 Subject: [PATCH 5/8] Refactor EventTile using the MVVM pattern - #7b (#33668) * Normalize Leaf Adapter Pattern * Extract simple EventTile adapters * Move simple EventTile adapter VM ownership to EventTileViewModel * Release listener-owning EventTile child VMs on adapter unmount * Extract stateful EventTile adapters * Move action bar VM ownership to EventTileViewModel * Move reactions row VM ownership into EventTileViewModel * Extract EventTile receipt rendering * Extract EventTile sender identity rendering * Add missing TSDoc on adapters --- .../src/components/views/rooms/EventTile.tsx | 734 ++---------------- .../rooms/EventTile/ActionBarAdapter.tsx | 188 +++++ .../EventTile/E2eMessageSharedIconAdapter.tsx | 67 ++ .../EventTile/E2eStandardPadlockIcon.tsx | 8 + .../EventTile/MessageTimestampAdapter.tsx | 52 ++ .../rooms/EventTile/ReactionsRowAdapter.tsx | 289 +++++++ .../views/rooms/EventTile/ReceiptAdapter.tsx | 121 +++ .../rooms/EventTile/SenderIdentityAdapter.tsx | 80 ++ .../EventTile/ThreadListActionBarAdapter.tsx | 49 ++ .../EventTile/ThreadMessagePreviewAdapter.tsx | 71 ++ .../rooms/EventTile/ThreadSummaryAdapter.tsx | 81 ++ .../E2eMessageSharedIconViewModel.ts | 8 + .../timeline/event-tile/EventTileViewModel.ts | 82 ++ 13 files changed, 1146 insertions(+), 684 deletions(-) create mode 100644 apps/web/src/components/views/rooms/EventTile/ActionBarAdapter.tsx create mode 100644 apps/web/src/components/views/rooms/EventTile/E2eMessageSharedIconAdapter.tsx create mode 100644 apps/web/src/components/views/rooms/EventTile/MessageTimestampAdapter.tsx create mode 100644 apps/web/src/components/views/rooms/EventTile/ReactionsRowAdapter.tsx create mode 100644 apps/web/src/components/views/rooms/EventTile/ReceiptAdapter.tsx create mode 100644 apps/web/src/components/views/rooms/EventTile/SenderIdentityAdapter.tsx create mode 100644 apps/web/src/components/views/rooms/EventTile/ThreadListActionBarAdapter.tsx create mode 100644 apps/web/src/components/views/rooms/EventTile/ThreadMessagePreviewAdapter.tsx create mode 100644 apps/web/src/components/views/rooms/EventTile/ThreadSummaryAdapter.tsx diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 907612b0ce4..2644edc7103 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -9,18 +9,13 @@ Please see LICENSE files in the repository root for full details. import React, { createRef, - useCallback, - useContext, useEffect, - useMemo, - useState, type JSX, type Ref, type FocusEvent, type MouseEvent, type ReactNode, } from "react"; -import classNames from "classnames"; import { type EventStatus, EventType, @@ -28,7 +23,6 @@ import { MatrixEventEvent, type Relations, type Room, - RelationsEvent, RoomEvent, type RoomMember, type Thread, @@ -36,22 +30,9 @@ import { } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call"; -import { Tooltip } from "@vector-im/compound-web"; -import { uniqueId, uniqBy } from "lodash"; -import { CircleIcon, CheckCircleIcon, ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -import { - useCreateAutoDisposedViewModel, - ActionBarView, - E2eMessageSharedIconView, - MessageTimestampView, - PinnedMessageBadge, - ReactionsRowButtonView, - ReactionsRowView, - ThreadMessagePreviewView, - ThreadSummaryView, - TileErrorView, - useViewModel, -} from "@element-hq/web-shared-components"; +import { uniqueId } from "lodash"; +import { ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { useCreateAutoDisposedViewModel, PinnedMessageBadge, TileErrorView } from "@element-hq/web-shared-components"; import ReplyChain from "../elements/ReplyChain"; import { _t } from "../../../languageHandler"; @@ -60,22 +41,16 @@ import { Layout } from "../../../settings/enums/Layout"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import RoomAvatar from "../avatars/RoomAvatar"; import MessageContextMenu from "../context_menus/MessageContextMenu"; -import ContextMenu, { aboveLeftOf, aboveRightOf } from "../../structures/ContextMenu"; +import { aboveRightOf } from "../../structures/ContextMenu"; import { objectHasDiff } from "../../../utils/objects"; import type EditorStateTransfer from "../../../utils/EditorStateTransfer"; import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; -import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; -import NotificationBadge from "./NotificationBadge"; import type LegacyCallEventGrouper from "../../structures/LegacyCallEventGrouper"; import { type ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import { Action } from "../../../dispatcher/actions"; import PlatformPeg from "../../../PlatformPeg"; -import MemberAvatar from "../avatars/MemberAvatar"; -import SenderProfile from "../messages/SenderProfile"; import { type IReadReceiptPosition } from "./ReadReceiptMarker"; -import ReactionPicker from "../emojipicker/ReactionPicker"; import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils"; -import { isContentActionable } from "../../../utils/EventUtils"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { copyPlaintext } from "../../../utils/strings"; @@ -83,22 +58,25 @@ import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import PosthogTrackers from "../../../PosthogTrackers"; import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../events/EventTileFactory"; -import { ReadReceiptGroup } from "./ReadReceiptGroup"; import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge"; import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper"; -import { Icon as LateIcon } from "../../../../res/img/sensor.svg"; import PinningUtils from "../../../utils/PinningUtils"; import { EventPreview } from "./EventPreview"; +import { ActionBarAdapter } from "./EventTile/ActionBarAdapter"; import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon"; -import SettingsStore from "../../../settings/SettingsStore"; -import { CardContext } from "../right_panel/context"; -import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; +import { E2eMessageSharedIconAdapter } from "./EventTile/E2eMessageSharedIconAdapter"; +import { MessageTimestampAdapter } from "./EventTile/MessageTimestampAdapter"; +import { ReactionsRowAdapter } from "./EventTile/ReactionsRowAdapter"; +import { ReceiptAdapter } from "./EventTile/ReceiptAdapter"; +import { EventTileAvatarAdapter, EventTileSenderAdapter } from "./EventTile/SenderIdentityAdapter"; +import { ThreadListActionBarAdapter } from "./EventTile/ThreadListActionBarAdapter"; +import { ThreadMessagePreviewAdapter } from "./EventTile/ThreadMessagePreviewAdapter"; +import { ThreadSummaryAdapter } from "./EventTile/ThreadSummaryAdapter"; import { EventTileViewModel, type EventTileViewModelProps, } from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; -import { E2eMessageSharedIconViewModel } from "../../../viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel"; import { getEventTileReceiptState, type EventTileReceiptState, @@ -121,28 +99,13 @@ import { initialEventTileInteractionState, type EventTileInteractionState, } from "../../../viewmodels/room/timeline/event-tile/EventTileInteractionState"; -import { - type MessageTimestampViewModel, - type MessageTimestampViewModelProps, -} from "../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts"; -import { - ThreadMessagePreviewViewModel, - ThreadSummaryViewModel, -} from "../../../viewmodels/room/timeline/event-tile/ThreadSummaryViewModel.tsx"; -import { ReactionsRowButtonViewModel } from "../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowButtonViewModel"; -import { - MAX_ITEMS_WHEN_LIMITED, - ReactionsRowViewModel, -} from "../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowViewModel"; +import { type MessageTimestampViewModelProps } from "../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts"; import { getEventTileReactionRelations, isEventTileReactionRelation, type GetRelationsForEvent, } from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState"; import { TileErrorViewModel } from "../../../viewmodels/message-body/TileErrorViewModel"; -import { EventTileActionBarViewModel } from "../../../viewmodels/room/EventTileActionBarViewModel"; -import { type ThreadListActionBarViewModel } from "../../../viewmodels/room/ThreadListActionBarViewModel"; -import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useSettingValue } from "../../../hooks/useSettings"; import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory"; import { EventTileE2eViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileE2eViewModel"; @@ -513,7 +476,7 @@ export class UnwrappedEventTile extends React.Component
{threadState.thread.length} - +
); } @@ -521,7 +484,8 @@ export class UnwrappedEventTile extends React.Component private renderThreadInfo(threadState: EventTileThreadState): React.ReactNode { if (threadState.shouldShowThreadSummary && threadState.thread) { return ( - return null; case "messageShared": return ( - @@ -1041,34 +1006,20 @@ export class UnwrappedEventTile extends React.Component // Local echos have a send "status". const scrollToken = eventTileRenderState.root.scrollToken; - let avatar: JSX.Element | null = null; - let sender: JSX.Element | null = null; - const { avatarSize } = eventTileSnapshot.sender.profileState; - - if (this.props.mxEvent.sender && avatarSize !== null) { - avatar = ( -
- -
- ); - } - - const senderProfileMode = eventTileSnapshot.sender.profileMode; - if (senderProfileMode === "clickable") { - sender = ; - } else if (senderProfileMode === "tooltip") { - sender = ; - } else if (senderProfileMode === "default") { - sender = ; - } + const avatar = ( + + ); + const sender = ( + + ); const actionBar = eventTileSnapshot.actionBar.show ? ( - ) : null; const timestamp = eventTileRenderState.timestamp.displayState.showRealTimestamp ? ( ) : ( @@ -1101,7 +1053,8 @@ export class UnwrappedEventTile extends React.Component ); const linkedTimestamp = eventTileRenderState.timestamp.displayState.showLinkedTimestamp ? ( ) : ( @@ -1116,7 +1069,8 @@ export class UnwrappedEventTile extends React.Component let reactionsRow: JSX.Element | undefined; if (hasReactionsRow) { reactionsRow = ( - const groupPadlock = eventTileRenderState.e2ePadlock.showInGroupLine && this.renderE2EPadlock(); const ircPadlock = eventTileRenderState.e2ePadlock.showInIrcLine && this.renderE2EPadlock(); - const receiptState = this.receiptState; - let msgOption: JSX.Element | undefined; - if (receiptState.shouldShowSentReceipt || receiptState.shouldShowSendingReceipt) { - msgOption = ; - } else if (this.props.showReadReceipts) { - msgOption = ( - - ); - } + const msgOption = ( + + ); const replyChainState = getEventTileReplyChainState({ mxEvent: this.props.mxEvent, @@ -1315,10 +1266,7 @@ export class UnwrappedEventTile extends React.Component {this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && ( { ); }; export default SafeEventTile; - -interface ISentReceiptProps { - messageState: EventStatus | undefined; -} - -function SentReceipt({ messageState }: ISentReceiptProps): JSX.Element { - const isSent = !messageState || messageState === "sent"; - const isFailed = messageState === "not_sent"; - - let icon: JSX.Element | undefined; - let label: string | undefined; - if (messageState === "encrypting") { - icon = ; - label = _t("timeline|send_state_encrypting"); - } else if (isSent) { - icon = ; - label = _t("timeline|send_state_sent"); - } else if (isFailed) { - icon = ; - label = _t("timeline|send_state_failed"); - } else { - icon = ; - label = _t("timeline|send_state_sending"); - } - - return ( -
-
- -
- {icon} -
-
-
-
- ); -} - -interface MessageTimestampAdapterProps { - vm: MessageTimestampViewModel; - timestampProps: MessageTimestampViewModelProps; -} - -function MessageTimestampAdapter({ vm, timestampProps }: Readonly): JSX.Element { - useEffect(() => { - vm.setProps(timestampProps); - }, [vm, timestampProps]); - - return ( - <> - {timestampProps.receivedTs ? ( - - ) : undefined} - - - ); -} - -interface ThreadMessagePreviewWrapperProps { - thread: Thread; - showDisplayName?: boolean; -} - -function ThreadMessagePreviewWrapper({ - thread, - showDisplayName = false, -}: Readonly): JSX.Element { - const cli = useMatrixClientContext(); - const { room, timelineRenderingType, lowBandwidth } = useScopedRoomContext( - "room", - "timelineRenderingType", - "lowBandwidth", - ); - const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles"); - const vm = useCreateAutoDisposedViewModel( - () => - new ThreadMessagePreviewViewModel({ - cli, - thread, - room, - timelineRenderingType, - lowBandwidth, - useOnlyCurrentProfiles, - showDisplayName, - avatarClassName: "mx_BaseAvatar", - }), - ); - - useEffect(() => { - vm.setClient(cli); - vm.setThread(thread); - vm.setRoom(room); - vm.setTimelineRenderingType(timelineRenderingType); - vm.setLowBandwidth(lowBandwidth); - vm.setUseOnlyCurrentProfiles(useOnlyCurrentProfiles); - vm.setShowDisplayName(showDisplayName); - }, [vm, cli, thread, room, timelineRenderingType, lowBandwidth, useOnlyCurrentProfiles, showDisplayName]); - - return ; -} - -interface ThreadSummaryWrapperProps extends Omit, "aria-label" | "onClick"> { - mxEvent: MatrixEvent; - thread: Thread; -} - -function ThreadSummaryWrapper({ - mxEvent, - thread, - className, - ...props -}: Readonly): JSX.Element { - const cli = useMatrixClientContext(); - const { isCard } = useContext(CardContext); - const { narrow, room, timelineRenderingType, lowBandwidth } = useScopedRoomContext( - "narrow", - "room", - "timelineRenderingType", - "lowBandwidth", - ); - const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles"); - const vm = useCreateAutoDisposedViewModel( - () => - new ThreadSummaryViewModel({ - cli, - mxEvent, - thread, - narrow, - isCard, - room, - timelineRenderingType, - lowBandwidth, - useOnlyCurrentProfiles, - avatarClassName: "mx_BaseAvatar", - }), - ); - - useEffect(() => { - vm.setClient(cli); - vm.setRootEvent(mxEvent); - vm.setThread(thread); - vm.setNarrow(narrow); - vm.setIsCard(isCard); - vm.setRoom(room); - vm.setTimelineRenderingType(timelineRenderingType); - vm.setLowBandwidth(lowBandwidth); - vm.setUseOnlyCurrentProfiles(useOnlyCurrentProfiles); - }, [vm, cli, mxEvent, thread, narrow, isCard, room, timelineRenderingType, lowBandwidth, useOnlyCurrentProfiles]); - - return ; -} - -interface ThreadListActionBarAdapterProps { - vm: ThreadListActionBarViewModel; - onViewInRoomClick: (anchor: HTMLElement | null) => void; - onCopyLinkClick: (anchor: HTMLElement | null) => void | Promise; - className?: string; -} - -function ThreadListActionBarAdapter({ - vm, - onViewInRoomClick, - onCopyLinkClick, - className, -}: Readonly): JSX.Element { - useEffect(() => { - vm.setProps({ - onViewInRoomClick, - onCopyLinkClick, - }); - }, [vm, onViewInRoomClick, onCopyLinkClick]); - - return ; -} - -interface ReactionsRowButtonItemProps { - mxEvent: MatrixEvent; - content: string; - count: number; - reactionEvents: MatrixEvent[]; - myReactionEvent?: MatrixEvent; - disabled?: boolean; - customReactionImagesEnabled?: boolean; -} - -function ReactionsRowButtonItem(props: Readonly): JSX.Element { - const client = useMatrixClientContext(); - - const vm = useCreateAutoDisposedViewModel( - () => - new ReactionsRowButtonViewModel({ - client, - mxEvent: props.mxEvent, - content: props.content, - count: props.count, - reactionEvents: props.reactionEvents, - myReactionEvent: props.myReactionEvent, - disabled: props.disabled, - customReactionImagesEnabled: props.customReactionImagesEnabled, - }), - ); - - useEffect(() => { - vm.setReactionData(props.content, props.reactionEvents, props.customReactionImagesEnabled); - }, [props.content, props.reactionEvents, props.customReactionImagesEnabled, vm]); - - useEffect(() => { - vm.setCount(props.count); - }, [props.count, vm]); - - useEffect(() => { - vm.setMyReactionEvent(props.myReactionEvent); - }, [props.myReactionEvent, vm]); - - useEffect(() => { - vm.setDisabled(props.disabled); - }, [props.disabled, vm]); - - return ; -} - -interface ReactionGroup { - content: string; - events: MatrixEvent[]; -} - -const getReactionGroups = (reactions?: Relations | null): ReactionGroup[] => - reactions - ?.getSortedAnnotationsByKey() - ?.map(([content, events]) => ({ - content, - events: [...events], - })) - .filter(({ events }) => events.length > 0) ?? []; - -const getMyReactions = (reactions: Relations | null | undefined, userId?: string): MatrixEvent[] | null => { - if (!reactions || !userId) { - return null; - } - - const myReactions = reactions.getAnnotationsBySender()?.[userId]; - if (!myReactions) { - return null; - } - - return [...myReactions.values()]; -}; - -interface ReactionsRowWrapperProps { - mxEvent: MatrixEvent; - reactions?: Relations | null; -} - -function ReactionsRowWrapper({ mxEvent, reactions }: Readonly): JSX.Element | null { - const roomContext = useContext(RoomContext); - const userId = roomContext.room?.client.getUserId() ?? undefined; - const [reactionGroups, setReactionGroups] = useState(() => getReactionGroups(reactions)); - const [myReactions, setMyReactions] = useState(() => getMyReactions(reactions, userId)); - const [menuDisplayed, setMenuDisplayed] = useState(false); - const [menuAnchorRect, setMenuAnchorRect] = useState(null); - - const vm = useCreateAutoDisposedViewModel( - () => - new ReactionsRowViewModel({ - isActionable: isContentActionable(mxEvent), - reactionGroupCount: reactionGroups.length, - canReact: roomContext.canReact, - addReactionButtonActive: false, - }), - ); - - const openReactionMenu = useCallback((event: React.MouseEvent): void => { - setMenuAnchorRect(event.currentTarget.getBoundingClientRect()); - setMenuDisplayed(true); - }, []); - - const closeReactionMenu = useCallback((): void => { - setMenuDisplayed(false); - }, []); - - const updateReactionsState = useCallback((): void => { - const nextReactionGroups = getReactionGroups(reactions); - setReactionGroups(nextReactionGroups); - setMyReactions(getMyReactions(reactions, userId)); - vm.setReactionGroupCount(nextReactionGroups.length); - }, [reactions, userId, vm]); - - useEffect(() => { - vm.setActionable(isContentActionable(mxEvent)); - }, [mxEvent, vm]); - - useEffect(() => { - vm.setCanReact(roomContext.canReact); - if (!roomContext.canReact && menuDisplayed) { - setMenuDisplayed(false); - } - }, [roomContext.canReact, menuDisplayed, vm]); - - useEffect(() => { - vm.setAddReactionHandlers({ - onAddReactionClick: openReactionMenu, - onAddReactionContextMenu: openReactionMenu, - }); - }, [openReactionMenu, vm]); - - useEffect(() => { - vm.setAddReactionButtonActive(menuDisplayed); - }, [menuDisplayed, vm]); - - useEffect(() => { - updateReactionsState(); - }, [updateReactionsState]); - - useEffect(() => { - if (!reactions) return; - - reactions.on(RelationsEvent.Add, updateReactionsState); - reactions.on(RelationsEvent.Remove, updateReactionsState); - reactions.on(RelationsEvent.Redaction, updateReactionsState); - - return () => { - reactions.off(RelationsEvent.Add, updateReactionsState); - reactions.off(RelationsEvent.Remove, updateReactionsState); - reactions.off(RelationsEvent.Redaction, updateReactionsState); - }; - }, [reactions, updateReactionsState]); - - useEffect(() => { - const onDecrypted = (): void => { - vm.setActionable(isContentActionable(mxEvent)); - }; - - if (mxEvent.isBeingDecrypted() || mxEvent.shouldAttemptDecryption()) { - mxEvent.once(MatrixEventEvent.Decrypted, onDecrypted); - } - - return () => { - mxEvent.off(MatrixEventEvent.Decrypted, onDecrypted); - }; - }, [mxEvent, vm]); - - const snapshot = useViewModel(vm); - const customReactionImagesEnabled = SettingsStore.getValue("feature_render_reaction_images"); - const items = useMemo((): JSX.Element[] | undefined => { - const mappedItems = reactionGroups.map(({ content, events }) => { - // Deduplicate reaction events by sender per Matrix spec. - const deduplicatedEvents = uniqBy(events, (event: MatrixEvent) => event.getSender()); - const myReactionEvent = myReactions?.find((reactionEvent) => { - if (reactionEvent.isRedacted()) { - return false; - } - return reactionEvent.getRelation()?.key === content; - }); - - return ( - - ); - }); - - if (!mappedItems.length) { - return undefined; - } - - return snapshot.showAllButtonVisible ? mappedItems.slice(0, MAX_ITEMS_WHEN_LIMITED) : mappedItems; - }, [ - reactionGroups, - myReactions, - mxEvent, - customReactionImagesEnabled, - roomContext.canReact, - roomContext.canSelfRedact, - snapshot.showAllButtonVisible, - ]); - - if (!snapshot.isVisible || !items?.length) { - return null; - } - - let contextMenu: JSX.Element | undefined; - if (menuDisplayed && menuAnchorRect && reactions && roomContext.canReact) { - contextMenu = ( - - - - ); - } - - return ( - <> - - {items} - - {contextMenu} - - ); -} - -interface ActionBarWrapperProps { - mxEvent: MatrixEvent; - reactions?: Relations | null; - permalinkCreator?: RoomPermalinkCreator; - getTile: () => IEventTileType | null; - getReplyChain: () => ReplyChain | null; - onFocusChange?: (focused: boolean) => void; - isQuoteExpanded?: boolean; - toggleThreadExpanded: () => void; - getRelationsForEvent?: GetRelationsForEvent; -} - -function ActionBarWrapper({ - mxEvent, - reactions, - permalinkCreator, - getTile, - getReplyChain, - onFocusChange, - isQuoteExpanded, - toggleThreadExpanded, - getRelationsForEvent, -}: Readonly): JSX.Element { - const roomContext = useContext(RoomContext); - const { isCard } = useContext(CardContext); - const [optionsMenuAnchorRect, setOptionsMenuAnchorRect] = useState(null); - const [reactionsMenuAnchorRect, setReactionsMenuAnchorRect] = useState(null); - const isSearch = Boolean(roomContext.search); - const handleOptionsClick = useCallback((anchor: HTMLElement | null): void => { - setOptionsMenuAnchorRect(anchor?.getBoundingClientRect() ?? null); - }, []); - const handleReactionsClick = useCallback((anchor: HTMLElement | null): void => { - setReactionsMenuAnchorRect(anchor?.getBoundingClientRect() ?? null); - }, []); - const vm = useCreateAutoDisposedViewModel( - () => - new EventTileActionBarViewModel({ - mxEvent, - timelineRenderingType: roomContext.timelineRenderingType, - canSendMessages: roomContext.canSendMessages, - canReact: roomContext.canReact, - isSearch, - isCard, - isQuoteExpanded, - onToggleThreadExpanded: toggleThreadExpanded, - onOptionsClick: handleOptionsClick, - onReactionsClick: handleReactionsClick, - getRelationsForEvent, - }), - ); - - useEffect(() => { - vm.setProps({ - mxEvent, - timelineRenderingType: roomContext.timelineRenderingType, - canSendMessages: roomContext.canSendMessages, - canReact: roomContext.canReact, - isSearch, - isCard, - isQuoteExpanded, - getRelationsForEvent, - onToggleThreadExpanded: toggleThreadExpanded, - onOptionsClick: handleOptionsClick, - onReactionsClick: handleReactionsClick, - }); - }, [ - vm, - mxEvent, - roomContext.timelineRenderingType, - roomContext.canSendMessages, - roomContext.canReact, - isSearch, - isCard, - isQuoteExpanded, - getRelationsForEvent, - handleOptionsClick, - handleReactionsClick, - toggleThreadExpanded, - ]); - - useEffect(() => { - onFocusChange?.(Boolean(optionsMenuAnchorRect || reactionsMenuAnchorRect)); - }, [onFocusChange, optionsMenuAnchorRect, reactionsMenuAnchorRect]); - - useEffect(() => { - setOptionsMenuAnchorRect(null); - setReactionsMenuAnchorRect(null); - }, [mxEvent]); - - const closeOptionsMenu = useCallback((): void => { - setOptionsMenuAnchorRect(null); - }, []); - - const closeReactionsMenu = useCallback((): void => { - setReactionsMenuAnchorRect(null); - }, []); - - const tile = getTile(); - const replyChain = getReplyChain(); - const eventTileOps = tile?.getEventTileOps ? tile.getEventTileOps() : undefined; - const collapseReplyChain = replyChain?.canCollapse() ? replyChain.collapse : undefined; - - return ( - <> - - {optionsMenuAnchorRect ? ( - - ) : null} - {reactionsMenuAnchorRect ? ( - - - - ) : null} - - ); -} - -interface E2eMessageSharedIconWrapperProps { - /** - * The ID of the room containing the event whose keys were shared. - */ - roomId: string; - /** - * The ID of the user who shared the keys. - */ - keyForwardingUserId: string; -} - -function E2eMessageSharedIconWrapper({ - roomId, - keyForwardingUserId, -}: Readonly): JSX.Element { - const client = useMatrixClientContext(); - const vm = useCreateAutoDisposedViewModel( - () => - new E2eMessageSharedIconViewModel({ - client, - roomId, - keyForwardingUserId, - }), - ); - - useEffect(() => { - vm.setRoomId(roomId); - }, [roomId, vm]); - - useEffect(() => { - vm.setKeyForwardingUserId(keyForwardingUserId); - }, [keyForwardingUserId, vm]); - - return ( - - ); -} diff --git a/apps/web/src/components/views/rooms/EventTile/ActionBarAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ActionBarAdapter.tsx new file mode 100644 index 00000000000..ef4ca843b91 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ActionBarAdapter.tsx @@ -0,0 +1,188 @@ +/* +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, { useCallback, useContext, useEffect, useState, type JSX } from "react"; +import { type MatrixEvent, type Relations } from "matrix-js-sdk/src/matrix"; +import { ActionBarView } from "@element-hq/web-shared-components"; + +import type ReplyChain from "../../elements/ReplyChain"; +import ReactionPicker from "../../emojipicker/ReactionPicker"; +import MessageContextMenu from "../../context_menus/MessageContextMenu"; +import ContextMenu, { aboveLeftOf } from "../../../structures/ContextMenu"; +import RoomContext from "../../../../contexts/RoomContext"; +import { CardContext } from "../../right_panel/context"; +import { type RoomPermalinkCreator } from "../../../../utils/permalinks/Permalinks"; +import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; +import { type GetRelationsForEvent } from "../../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState"; + +/** + * Operations exposed by the event tile for action bar interactions. + */ +interface ActionBarEventTileOps { + /** Returns whether the widget is currently hidden. */ + isWidgetHidden(): boolean; + /** Unhides the widget if it was previously hidden. */ + unhideWidget(): void; +} + +/** + * Event tile handle used by the action bar to query tile-level operations. + */ +interface ActionBarEventTile { + /** Returns the available tile operations, if any. */ + getEventTileOps?(): ActionBarEventTileOps; +} + +/** + * Props for the {@link ActionBarAdapter} component. + */ +interface ActionBarAdapterProps { + /** View model backing the event tile action bar. */ + eventTileViewModel: EventTileViewModel; + /** Matrix event rendered by this tile. */ + mxEvent: MatrixEvent; + /** Reaction relation state for the event, if available. */ + reactions?: Relations | null; + /** Creates permalinks for the room, when link actions are shown. */ + permalinkCreator?: RoomPermalinkCreator; + /** Returns the current tile instance for event tile operations. */ + getTile: () => ActionBarEventTile | null; + /** Returns the current reply chain for the tile, if any. */ + getReplyChain: () => ReplyChain | null; + /** Notifies the parent when the action bar gains or loses focus. */ + onFocusChange?: (focused: boolean) => void; + /** Indicates whether the event quote is currently expanded. */ + isQuoteExpanded?: boolean; + /** Toggles the thread expansion state for this tile. */ + toggleThreadExpanded: () => void; + /** Looks up relation data for the current event. */ + getRelationsForEvent?: GetRelationsForEvent; +} + +/** + * Renders the event tile action bar and its context menus. + */ +export function ActionBarAdapter({ + eventTileViewModel, + mxEvent, + reactions, + permalinkCreator, + getTile, + getReplyChain, + onFocusChange, + isQuoteExpanded, + toggleThreadExpanded, + getRelationsForEvent, +}: Readonly): JSX.Element { + const roomContext = useContext(RoomContext); + const { isCard } = useContext(CardContext); + const [optionsMenuAnchorRect, setOptionsMenuAnchorRect] = useState(null); + const [reactionsMenuAnchorRect, setReactionsMenuAnchorRect] = useState(null); + const isSearch = Boolean(roomContext.search); + const handleOptionsClick = useCallback((anchor: HTMLElement | null): void => { + setOptionsMenuAnchorRect(anchor?.getBoundingClientRect() ?? null); + }, []); + const handleReactionsClick = useCallback((anchor: HTMLElement | null): void => { + setReactionsMenuAnchorRect(anchor?.getBoundingClientRect() ?? null); + }, []); + const vm = eventTileViewModel.getActionBarViewModel({ + mxEvent, + timelineRenderingType: roomContext.timelineRenderingType, + canSendMessages: roomContext.canSendMessages, + canReact: roomContext.canReact, + isSearch, + isCard, + isQuoteExpanded, + onToggleThreadExpanded: toggleThreadExpanded, + onOptionsClick: handleOptionsClick, + onReactionsClick: handleReactionsClick, + getRelationsForEvent, + }); + + useEffect(() => { + // This child VM owns Matrix and settings listeners, so release it when the view using it leaves the tree. + return () => eventTileViewModel.releaseActionBarViewModel(); + }, [eventTileViewModel]); + + useEffect(() => { + vm.setProps({ + mxEvent, + timelineRenderingType: roomContext.timelineRenderingType, + canSendMessages: roomContext.canSendMessages, + canReact: roomContext.canReact, + isSearch, + isCard, + isQuoteExpanded, + getRelationsForEvent, + onToggleThreadExpanded: toggleThreadExpanded, + onOptionsClick: handleOptionsClick, + onReactionsClick: handleReactionsClick, + }); + }, [ + vm, + mxEvent, + roomContext.timelineRenderingType, + roomContext.canSendMessages, + roomContext.canReact, + isSearch, + isCard, + isQuoteExpanded, + getRelationsForEvent, + handleOptionsClick, + handleReactionsClick, + toggleThreadExpanded, + ]); + + useEffect(() => { + onFocusChange?.(Boolean(optionsMenuAnchorRect || reactionsMenuAnchorRect)); + }, [onFocusChange, optionsMenuAnchorRect, reactionsMenuAnchorRect]); + + useEffect(() => { + setOptionsMenuAnchorRect(null); + setReactionsMenuAnchorRect(null); + }, [mxEvent]); + + const closeOptionsMenu = useCallback((): void => { + setOptionsMenuAnchorRect(null); + }, []); + + const closeReactionsMenu = useCallback((): void => { + setReactionsMenuAnchorRect(null); + }, []); + + const tile = getTile(); + const replyChain = getReplyChain(); + const eventTileOps = tile?.getEventTileOps ? tile.getEventTileOps() : undefined; + const collapseReplyChain = replyChain?.canCollapse() ? replyChain.collapse : undefined; + + return ( + <> + + {optionsMenuAnchorRect ? ( + + ) : null} + {reactionsMenuAnchorRect ? ( + + + + ) : null} + + ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/E2eMessageSharedIconAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/E2eMessageSharedIconAdapter.tsx new file mode 100644 index 00000000000..b470cf9ce3c --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/E2eMessageSharedIconAdapter.tsx @@ -0,0 +1,67 @@ +/* +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, { useEffect, type JSX } from "react"; +import { E2eMessageSharedIconView } from "@element-hq/web-shared-components"; + +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; + +/** + * Props for the {@link E2eMessageSharedIconAdapter} component. + */ +interface E2eMessageSharedIconAdapterProps { + /** View model backing the shared-key indicator. */ + eventTileViewModel: EventTileViewModel; + /** The ID of the room containing the event whose keys were shared. */ + roomId: string; + /** The ID of the user who shared the keys. */ + keyForwardingUserId: string; +} + +/** + * Renders the end-to-end encryption key-sharing indicator. + */ +export function E2eMessageSharedIconAdapter({ + eventTileViewModel, + roomId, + keyForwardingUserId, +}: Readonly): JSX.Element { + const client = useMatrixClientContext(); + const vm = eventTileViewModel.getE2eMessageSharedIconViewModel({ + client, + roomId, + keyForwardingUserId, + }); + + useEffect(() => { + // This child VM owns Matrix listeners, so release it when the view using it leaves the tree. + return () => eventTileViewModel.releaseE2eMessageSharedIconViewModel(); + }, [eventTileViewModel]); + + useEffect(() => { + vm.setClient(client); + }, [client, vm]); + + useEffect(() => { + vm.setRoomId(roomId); + }, [roomId, vm]); + + useEffect(() => { + vm.setKeyForwardingUserId(keyForwardingUserId); + }, [keyForwardingUserId, vm]); + + return ( + + ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/E2eStandardPadlockIcon.tsx b/apps/web/src/components/views/rooms/EventTile/E2eStandardPadlockIcon.tsx index ade790e2db5..131c88d6c56 100644 --- a/apps/web/src/components/views/rooms/EventTile/E2eStandardPadlockIcon.tsx +++ b/apps/web/src/components/views/rooms/EventTile/E2eStandardPadlockIcon.tsx @@ -8,11 +8,19 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX } from "react"; import { E2ePadlock, type E2ePadlockIcon } from "@element-hq/web-shared-components"; +/** + * Props for the {@link E2eStandardPadlockIcon} component. + */ interface E2eStandardPadlockIconProps { + /** Icon variant to render. */ icon: E2ePadlockIcon; + /** Accessible title for the icon. */ title: string; } +/** + * Renders the standard end-to-end encryption padlock icon. + */ export function E2eStandardPadlockIcon({ icon, title }: Readonly): JSX.Element { return ( ): JSX.Element { + const vm = + kind === "linked" + ? eventTileViewModel.getLinkedMessageTimestampViewModel(timestampProps) + : eventTileViewModel.getMessageTimestampViewModel(timestampProps); + + useEffect(() => { + vm.setProps(timestampProps); + }, [vm, timestampProps]); + + return ( + <> + {timestampProps.receivedTs ? ( + + ) : undefined} + + + ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/ReactionsRowAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ReactionsRowAdapter.tsx new file mode 100644 index 00000000000..f832a5604ae --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ReactionsRowAdapter.tsx @@ -0,0 +1,289 @@ +/* +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, { useCallback, useContext, useEffect, useMemo, useState, type JSX } from "react"; +import { MatrixEventEvent, type MatrixEvent, type Relations, RelationsEvent } from "matrix-js-sdk/src/matrix"; +import { uniqBy } from "lodash"; +import { + ReactionsRowButtonView, + ReactionsRowView, + useCreateAutoDisposedViewModel, + useViewModel, +} from "@element-hq/web-shared-components"; + +import ContextMenu, { aboveLeftOf } from "../../../structures/ContextMenu"; +import ReactionPicker from "../../emojipicker/ReactionPicker"; +import RoomContext from "../../../../contexts/RoomContext"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import SettingsStore from "../../../../settings/SettingsStore"; +import { isContentActionable } from "../../../../utils/EventUtils"; +import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; +import { ReactionsRowButtonViewModel } from "../../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowButtonViewModel"; +import { MAX_ITEMS_WHEN_LIMITED } from "../../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowViewModel"; + +/** + * Props for the {@link ReactionsRowButtonAdapter} component. + */ +interface ReactionsRowButtonAdapterProps { + /** Matrix event whose reaction button is being rendered. */ + mxEvent: MatrixEvent; + /** Reaction emoji or custom content key for this button. */ + content: string; + /** Total number of reactions in this group. */ + count: number; + /** Reaction events belonging to this group. */ + reactionEvents: MatrixEvent[]; + /** The current user's reaction event, if present. */ + myReactionEvent?: MatrixEvent; + /** Disables interaction when true. */ + disabled?: boolean; + /** Enables rendering custom reaction images. */ + customReactionImagesEnabled?: boolean; +} + +/** + * Renders a single reaction button within the event tile reaction row. + */ +function ReactionsRowButtonAdapter(props: Readonly): JSX.Element { + const client = useMatrixClientContext(); + + const vm = useCreateAutoDisposedViewModel( + () => + new ReactionsRowButtonViewModel({ + client, + mxEvent: props.mxEvent, + content: props.content, + count: props.count, + reactionEvents: props.reactionEvents, + myReactionEvent: props.myReactionEvent, + disabled: props.disabled, + customReactionImagesEnabled: props.customReactionImagesEnabled, + }), + ); + + useEffect(() => { + vm.setReactionData(props.content, props.reactionEvents, props.customReactionImagesEnabled); + }, [props.content, props.reactionEvents, props.customReactionImagesEnabled, vm]); + + useEffect(() => { + vm.setCount(props.count); + }, [props.count, vm]); + + useEffect(() => { + vm.setMyReactionEvent(props.myReactionEvent); + }, [props.myReactionEvent, vm]); + + useEffect(() => { + vm.setDisabled(props.disabled); + }, [props.disabled, vm]); + + return ; +} + +interface ReactionGroup { + content: string; + events: MatrixEvent[]; +} + +const getReactionGroups = (reactions?: Relations | null): ReactionGroup[] => + reactions + ?.getSortedAnnotationsByKey() + ?.map(([content, events]) => ({ + content, + events: [...events], + })) + .filter(({ events }) => events.length > 0) ?? []; + +const getMyReactions = (reactions: Relations | null | undefined, userId?: string): MatrixEvent[] | null => { + if (!reactions || !userId) { + return null; + } + + const myReactions = reactions.getAnnotationsBySender()?.[userId]; + if (!myReactions) { + return null; + } + + return [...myReactions.values()]; +}; + +/** + * Props for the {@link ReactionsRowAdapter} component. + */ +interface ReactionsRowAdapterProps { + /** View model backing the event tile reaction row. */ + eventTileViewModel: EventTileViewModel; + /** Matrix event whose reactions are being rendered. */ + mxEvent: MatrixEvent; + /** Current reaction relations for the event, if available. */ + reactions?: Relations | null; +} + +/** + * Renders the reaction row and reaction picker for an event tile. + */ +export function ReactionsRowAdapter({ + eventTileViewModel, + mxEvent, + reactions, +}: Readonly): JSX.Element | null { + const roomContext = useContext(RoomContext); + const userId = roomContext.room?.client.getUserId() ?? undefined; + const [reactionGroups, setReactionGroups] = useState(() => getReactionGroups(reactions)); + const [myReactions, setMyReactions] = useState(() => getMyReactions(reactions, userId)); + const [menuDisplayed, setMenuDisplayed] = useState(false); + const [menuAnchorRect, setMenuAnchorRect] = useState(null); + + const vm = eventTileViewModel.getReactionsRowViewModel({ + isActionable: isContentActionable(mxEvent), + reactionGroupCount: reactionGroups.length, + canReact: roomContext.canReact, + addReactionButtonActive: false, + }); + + useEffect(() => { + // This child VM is owned by EventTileViewModel, but scoped to this rendered adapter surface. + return () => eventTileViewModel.releaseReactionsRowViewModel(); + }, [eventTileViewModel]); + + const openReactionMenu = useCallback((event: React.MouseEvent): void => { + setMenuAnchorRect(event.currentTarget.getBoundingClientRect()); + setMenuDisplayed(true); + }, []); + + const closeReactionMenu = useCallback((): void => { + setMenuDisplayed(false); + }, []); + + const updateReactionsState = useCallback((): void => { + const nextReactionGroups = getReactionGroups(reactions); + setReactionGroups(nextReactionGroups); + setMyReactions(getMyReactions(reactions, userId)); + vm.setReactionGroupCount(nextReactionGroups.length); + }, [reactions, userId, vm]); + + useEffect(() => { + vm.setActionable(isContentActionable(mxEvent)); + }, [mxEvent, vm]); + + useEffect(() => { + vm.setCanReact(roomContext.canReact); + if (!roomContext.canReact && menuDisplayed) { + setMenuDisplayed(false); + } + }, [roomContext.canReact, menuDisplayed, vm]); + + useEffect(() => { + vm.setAddReactionHandlers({ + onAddReactionClick: openReactionMenu, + onAddReactionContextMenu: openReactionMenu, + }); + }, [openReactionMenu, vm]); + + useEffect(() => { + vm.setAddReactionButtonActive(menuDisplayed); + }, [menuDisplayed, vm]); + + useEffect(() => { + updateReactionsState(); + }, [updateReactionsState]); + + useEffect(() => { + if (!reactions) return; + + reactions.on(RelationsEvent.Add, updateReactionsState); + reactions.on(RelationsEvent.Remove, updateReactionsState); + reactions.on(RelationsEvent.Redaction, updateReactionsState); + + return () => { + reactions.off(RelationsEvent.Add, updateReactionsState); + reactions.off(RelationsEvent.Remove, updateReactionsState); + reactions.off(RelationsEvent.Redaction, updateReactionsState); + }; + }, [reactions, updateReactionsState]); + + useEffect(() => { + const onDecrypted = (): void => { + vm.setActionable(isContentActionable(mxEvent)); + }; + + if (mxEvent.isBeingDecrypted() || mxEvent.shouldAttemptDecryption()) { + mxEvent.once(MatrixEventEvent.Decrypted, onDecrypted); + } + + return () => { + mxEvent.off(MatrixEventEvent.Decrypted, onDecrypted); + }; + }, [mxEvent, vm]); + + const snapshot = useViewModel(vm); + const customReactionImagesEnabled = SettingsStore.getValue("feature_render_reaction_images"); + const items = useMemo((): JSX.Element[] | undefined => { + const mappedItems = reactionGroups.map(({ content, events }) => { + // Deduplicate reaction events by sender per Matrix spec. + const deduplicatedEvents = uniqBy(events, (event: MatrixEvent) => event.getSender()); + const myReactionEvent = myReactions?.find((reactionEvent) => { + if (reactionEvent.isRedacted()) { + return false; + } + return reactionEvent.getRelation()?.key === content; + }); + + return ( + + ); + }); + + if (!mappedItems.length) { + return undefined; + } + + return snapshot.showAllButtonVisible ? mappedItems.slice(0, MAX_ITEMS_WHEN_LIMITED) : mappedItems; + }, [ + reactionGroups, + myReactions, + mxEvent, + customReactionImagesEnabled, + roomContext.canReact, + roomContext.canSelfRedact, + snapshot.showAllButtonVisible, + ]); + + if (!snapshot.isVisible || !items?.length) { + return null; + } + + let contextMenu: JSX.Element | undefined; + if (menuDisplayed && menuAnchorRect && reactions && roomContext.canReact) { + contextMenu = ( + + + + ); + } + + return ( + <> + + {items} + + {contextMenu} + + ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/ReceiptAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ReceiptAdapter.tsx new file mode 100644 index 00000000000..58f8ccbecb5 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ReceiptAdapter.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 } from "react"; +import { type EventStatus, type RoomMember } from "matrix-js-sdk/src/matrix"; +import { Tooltip } from "@vector-im/compound-web"; +import { CheckCircleIcon, CircleIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { _t } from "../../../../languageHandler"; +import { StaticNotificationState } from "../../../../stores/notifications/StaticNotificationState"; +import NotificationBadge from "../NotificationBadge"; +import { ReadReceiptGroup } from "../ReadReceiptGroup"; +import { type IReadReceiptPosition } from "../ReadReceiptMarker"; +import { type EventTileReceiptState } from "../../../../viewmodels/room/timeline/event-tile/EventTileReceiptState"; + +/** + * A single read receipt entry displayed in the event tile receipt row. + */ +interface ReadReceiptProps { + /** The user ID associated with the receipt. */ + userId: string; + /** The room member metadata for the user, if available. */ + roomMember: RoomMember | null; + /** Timestamp of the receipt in milliseconds since the Unix epoch. */ + ts: number; +} + +/** + * Props for the {@link ReceiptAdapter} component. + */ +interface ReceiptAdapterProps { + /** Current receipt state for the event tile. */ + receiptState: EventTileReceiptState; + /** Send status used when rendering the sent-state badge. */ + eventSendStatus?: EventStatus; + /** Whether read receipts should be shown. */ + showReadReceipts?: boolean; + /** Read receipts to render when the group is visible. */ + readReceipts?: ReadReceiptProps[]; + /** Read receipt positions keyed by user ID. */ + readReceiptMap?: { [userId: string]: IReadReceiptPosition }; + /** Checks whether the receipt row is unmounting. */ + checkUnmounting?: () => boolean; + /** Suppresses receipt animation when true. */ + suppressAnimation: boolean; + /** Whether timestamps should use a 12-hour clock. */ + isTwelveHour?: boolean; +} + +/** + * Renders the send-state or read-receipt indicator for an event tile. + */ +export function ReceiptAdapter({ + receiptState, + eventSendStatus, + showReadReceipts, + readReceipts, + readReceiptMap, + checkUnmounting, + suppressAnimation, + isTwelveHour, +}: Readonly): JSX.Element | null { + if (receiptState.shouldShowSentReceipt || receiptState.shouldShowSendingReceipt) { + return ; + } + + if (!showReadReceipts) { + return null; + } + + return ( + + ); +} + +interface SentReceiptProps { + messageState: EventStatus | undefined; +} + +function SentReceipt({ messageState }: Readonly): JSX.Element { + const isSent = !messageState || messageState === "sent"; + const isFailed = messageState === "not_sent"; + + let icon: JSX.Element | undefined; + let label: string | undefined; + if (messageState === "encrypting") { + icon = ; + label = _t("timeline|send_state_encrypting"); + } else if (isSent) { + icon = ; + label = _t("timeline|send_state_sent"); + } else if (isFailed) { + icon = ; + label = _t("timeline|send_state_failed"); + } else { + icon = ; + label = _t("timeline|send_state_sending"); + } + + return ( +
+
+ +
+ {icon} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/SenderIdentityAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/SenderIdentityAdapter.tsx new file mode 100644 index 00000000000..eb60ac2952b --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/SenderIdentityAdapter.tsx @@ -0,0 +1,80 @@ +/* +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 } from "react"; +import { EventType, type MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import MemberAvatar from "../../avatars/MemberAvatar"; +import SenderProfile from "../../messages/SenderProfile"; +import { type EventTileSenderSnapshot } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; + +/** + * Props for the {@link EventTileAvatarAdapter} component. + */ +interface EventTileAvatarAdapterProps { + /** Matrix event whose sender avatar is being rendered. */ + mxEvent: MatrixEvent; + /** Snapshot of the sender identity state for this tile. */ + senderSnapshot: EventTileSenderSnapshot; +} + +/** + * Renders the sender avatar for an event tile. + */ +export function EventTileAvatarAdapter({ + mxEvent, + senderSnapshot, +}: Readonly): JSX.Element | null { + const { avatarSize } = senderSnapshot.profileState; + + if (!mxEvent.sender || avatarSize === null) { + return null; + } + + return ( +
+ +
+ ); +} + +/** + * Props for the {@link EventTileSenderAdapter} component. + */ +interface EventTileSenderAdapterProps { + /** Matrix event whose sender identity is being rendered. */ + mxEvent: MatrixEvent; + /** Snapshot of the sender identity state for this tile. */ + senderSnapshot: EventTileSenderSnapshot; + /** Invoked when the sender profile is clicked. */ + onSenderProfileClick: () => void; +} + +/** + * Renders the sender identity display for an event tile. + */ +export function EventTileSenderAdapter({ + mxEvent, + senderSnapshot, + onSenderProfileClick, +}: Readonly): JSX.Element | null { + switch (senderSnapshot.profileMode) { + case "clickable": + return ; + case "tooltip": + return ; + case "default": + return ; + default: + return null; + } +} diff --git a/apps/web/src/components/views/rooms/EventTile/ThreadListActionBarAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ThreadListActionBarAdapter.tsx new file mode 100644 index 00000000000..9d7c23225ef --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ThreadListActionBarAdapter.tsx @@ -0,0 +1,49 @@ +/* +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, { useEffect, type JSX } from "react"; +import { ActionBarView } from "@element-hq/web-shared-components"; + +import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; + +/** + * Props for the {@link ThreadListActionBarAdapter} component. + */ +interface ThreadListActionBarAdapterProps { + /** View model backing the thread list action bar. */ + eventTileViewModel: EventTileViewModel; + /** Opens the event in the room timeline. */ + onViewInRoomClick: (anchor: HTMLElement | null) => void; + /** Copies the event permalink to the clipboard. */ + onCopyLinkClick: (anchor: HTMLElement | null) => void | Promise; + /** Optional class name applied to the action bar. */ + className?: string; +} + +/** + * Renders the action bar used by the thread list view. + */ +export function ThreadListActionBarAdapter({ + eventTileViewModel, + onViewInRoomClick, + onCopyLinkClick, + className, +}: Readonly): JSX.Element { + const vm = eventTileViewModel.getThreadListActionBarViewModel({ + onViewInRoomClick, + onCopyLinkClick, + }); + + useEffect(() => { + vm.setProps({ + onViewInRoomClick, + onCopyLinkClick, + }); + }, [vm, onViewInRoomClick, onCopyLinkClick]); + + return ; +} diff --git a/apps/web/src/components/views/rooms/EventTile/ThreadMessagePreviewAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ThreadMessagePreviewAdapter.tsx new file mode 100644 index 00000000000..33de8868322 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ThreadMessagePreviewAdapter.tsx @@ -0,0 +1,71 @@ +/* +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, { useEffect, type JSX } from "react"; +import { type Thread } from "matrix-js-sdk/src/matrix"; +import { ThreadMessagePreviewView } from "@element-hq/web-shared-components"; + +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import { useScopedRoomContext } from "../../../../contexts/ScopedRoomContext.tsx"; +import { useSettingValue } from "../../../../hooks/useSettings"; +import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; + +/** + * Props for the {@link ThreadMessagePreviewAdapter} component. + */ +interface ThreadMessagePreviewAdapterProps { + /** View model backing the thread preview. */ + eventTileViewModel: EventTileViewModel; + /** Thread whose message preview is rendered. */ + thread: Thread; + /** Whether the sender display name should be shown. */ + showDisplayName?: boolean; +} + +/** + * Renders the preview shown for a thread message. + */ +export function ThreadMessagePreviewAdapter({ + eventTileViewModel, + thread, + showDisplayName = false, +}: Readonly): JSX.Element { + const cli = useMatrixClientContext(); + const { room, timelineRenderingType, lowBandwidth } = useScopedRoomContext( + "room", + "timelineRenderingType", + "lowBandwidth", + ); + const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles"); + const vm = eventTileViewModel.getThreadMessagePreviewViewModel({ + cli, + thread, + room, + timelineRenderingType, + lowBandwidth, + useOnlyCurrentProfiles, + showDisplayName, + avatarClassName: "mx_BaseAvatar", + }); + + useEffect(() => { + // This child VM owns Matrix listeners, so release it when the view using it leaves the tree. + return () => eventTileViewModel.releaseThreadMessagePreviewViewModel(); + }, [eventTileViewModel]); + + useEffect(() => { + vm.setClient(cli); + vm.setThread(thread); + vm.setRoom(room); + vm.setTimelineRenderingType(timelineRenderingType); + vm.setLowBandwidth(lowBandwidth); + vm.setUseOnlyCurrentProfiles(useOnlyCurrentProfiles); + vm.setShowDisplayName(showDisplayName); + }, [vm, cli, thread, room, timelineRenderingType, lowBandwidth, useOnlyCurrentProfiles, showDisplayName]); + + return ; +} diff --git a/apps/web/src/components/views/rooms/EventTile/ThreadSummaryAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ThreadSummaryAdapter.tsx new file mode 100644 index 00000000000..4f12d4c44f1 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ThreadSummaryAdapter.tsx @@ -0,0 +1,81 @@ +/* +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, { useContext, useEffect, type JSX } from "react"; +import classNames from "classnames"; +import { type MatrixEvent, type Thread } from "matrix-js-sdk/src/matrix"; +import { ThreadSummaryView } from "@element-hq/web-shared-components"; + +import { CardContext } from "../../right_panel/context"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import { useScopedRoomContext } from "../../../../contexts/ScopedRoomContext.tsx"; +import { useSettingValue } from "../../../../hooks/useSettings"; +import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; + +/** + * Props for the {@link ThreadSummaryAdapter} component. + */ +interface ThreadSummaryAdapterProps extends Omit, "aria-label" | "onClick"> { + /** View model backing the thread summary tile. */ + eventTileViewModel: EventTileViewModel; + /** Root event for the thread summary. */ + mxEvent: MatrixEvent; + /** Thread represented by the summary tile. */ + thread: Thread; +} + +/** + * Renders the thread summary tile for an event. + */ +export function ThreadSummaryAdapter({ + eventTileViewModel, + mxEvent, + thread, + className, + ...props +}: Readonly): JSX.Element { + const cli = useMatrixClientContext(); + const { isCard } = useContext(CardContext); + const { narrow, room, timelineRenderingType, lowBandwidth } = useScopedRoomContext( + "narrow", + "room", + "timelineRenderingType", + "lowBandwidth", + ); + const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles"); + const vm = eventTileViewModel.getThreadSummaryViewModel({ + cli, + mxEvent, + thread, + narrow, + isCard, + room, + timelineRenderingType, + lowBandwidth, + useOnlyCurrentProfiles, + avatarClassName: "mx_BaseAvatar", + }); + + useEffect(() => { + // This child VM owns Matrix listeners, so release it when the view using it leaves the tree. + return () => eventTileViewModel.releaseThreadSummaryViewModel(); + }, [eventTileViewModel]); + + useEffect(() => { + vm.setClient(cli); + vm.setRootEvent(mxEvent); + vm.setThread(thread); + vm.setNarrow(narrow); + vm.setIsCard(isCard); + vm.setRoom(room); + vm.setTimelineRenderingType(timelineRenderingType); + vm.setLowBandwidth(lowBandwidth); + vm.setUseOnlyCurrentProfiles(useOnlyCurrentProfiles); + }, [vm, cli, mxEvent, thread, narrow, isCard, room, timelineRenderingType, lowBandwidth, useOnlyCurrentProfiles]); + + return ; +} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel.ts index c992b4f54be..70e5464c0d4 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel.ts @@ -33,6 +33,14 @@ export class E2eMessageSharedIconViewModel this.disposables.track(() => this.teardownRoomStateListener()); } + public setClient(client: MatrixClient): void { + if (this.props.client === client) return; + + this.props = { ...this.props, client }; + this.setupRoomStateListener(); + this.updateSnapshotFromProps(); + } + public setRoomId(roomId: string): void { if (this.props.roomId === roomId) return; diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts index ac6800bee97..f707582036b 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts @@ -34,10 +34,22 @@ import { import { TimelineRenderingType } from "../../../../contexts/RoomContext"; import { type Layout } from "../../../../settings/enums/Layout"; import { MessageTimestampViewModel, type MessageTimestampViewModelProps } from "./timestamp/MessageTimestampViewModel"; +import { + ThreadMessagePreviewViewModel, + type ThreadMessagePreviewViewModelProps, + ThreadSummaryViewModel, + type ThreadSummaryViewModelProps, +} from "./ThreadSummaryViewModel.tsx"; +import { + E2eMessageSharedIconViewModel, + type E2eMessageSharedIconViewModelProps, +} from "./E2eMessageSharedIconViewModel"; import { ThreadListActionBarViewModel, type ThreadListActionBarViewModelProps, } from "../../ThreadListActionBarViewModel"; +import { EventTileActionBarViewModel, type EventTileActionBarViewModelProps } from "../../EventTileActionBarViewModel"; +import { ReactionsRowViewModel, type ReactionsRowViewModelProps } from "./reactions/ReactionsRowViewModel"; /** Event-level inputs for deriving the EventTile snapshot. */ export interface EventTileEventInput { @@ -285,7 +297,12 @@ export interface EventTileRenderState { export class EventTileViewModel extends BaseViewModel { private messageTimestampViewModel?: MessageTimestampViewModel; private linkedMessageTimestampViewModel?: MessageTimestampViewModel; + private threadMessagePreviewViewModel?: ThreadMessagePreviewViewModel; + private threadSummaryViewModel?: ThreadSummaryViewModel; private threadListActionBarViewModel?: ThreadListActionBarViewModel; + private e2eMessageSharedIconViewModel?: E2eMessageSharedIconViewModel; + private actionBarViewModel?: EventTileActionBarViewModel; + private reactionsRowViewModel?: ReactionsRowViewModel; public constructor(props: EventTileViewModelProps) { const initialRenderState = EventTileViewModel.createRenderState(props); @@ -302,7 +319,12 @@ export class EventTileViewModel extends BaseViewModel Date: Mon, 1 Jun 2026 16:44:28 +0300 Subject: [PATCH 6/8] fix: use configured brand name in JSON and PlainText chat export filenames (#33680) * fix: use configured brand name in JSON and PlainText chat export filenames The JSON and PlainText chat export functions were using the hardcoded default 'matrix' as the brand name in filenames, while the HTML/ZIP export correctly used the configured brand (e.g., 'Element'). This made the filenames inconsistent: - ZIP: Element - Room - Chat Export - timestamp.zip - JSON: matrix - Room - Chat Export - timestamp.json Now both JSON and PlainText exports pass SdkConfig.get().brand to makeFileNameNoExtension(), matching the behavior of the base Exporter class used by HTML exports. Fixes #32853 Signed-off-by: RoySerbi * refactor: read brand from SdkConfig inside makeFileNameNoExtension Address review feedback from @t3chguy: the brand parameter on makeFileNameNoExtension was effectively dead (all three call sites passed SdkConfig.get().brand and the 'matrix' default was unreachable). - Exporter.makeFileNameNoExtension now reads SdkConfig.get().brand directly - All call sites (Exporter, JSONExport, PlainTextExport) simplified to no-arg - Drop now-unused SdkConfig imports in JSONExport and PlainTextExport Tests are unchanged: they already mock SdkConfig to return 'Element', so the existing 'Element-branded destination file name' snapshots remain valid. --------- Signed-off-by: RoySerbi --- apps/web/src/utils/exportUtils/Exporter.ts | 6 +++--- .../test/unit-tests/utils/exportUtils/JSONExport-test.ts | 2 +- .../unit-tests/utils/exportUtils/PlainTextExport-test.ts | 2 +- .../utils/exportUtils/__snapshots__/JSONExport-test.ts.snap | 2 +- .../exportUtils/__snapshots__/PlainTextExport-test.ts.snap | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/web/src/utils/exportUtils/Exporter.ts b/apps/web/src/utils/exportUtils/Exporter.ts index 185507e8cc0..1c535db5d48 100644 --- a/apps/web/src/utils/exportUtils/Exporter.ts +++ b/apps/web/src/utils/exportUtils/Exporter.ts @@ -56,7 +56,7 @@ export default abstract class Exporter { } public get destinationFileName(): string { - return this.makeFileNameNoExtension(SdkConfig.get().brand) + ".zip"; + return this.makeFileNameNoExtension() + ".zip"; } protected onBeforeUnload(this: void, e: BeforeUnloadEvent): string { @@ -77,12 +77,12 @@ export default abstract class Exporter { this.files.push(file); } - protected makeFileNameNoExtension(brand = "matrix"): string { + protected makeFileNameNoExtension(): string { // First try to use the real name of the room, then a translated copy of a generic name, // then finally hardcoded default to guarantee we'll have a name. const safeRoomName = sanitizeFilename(this.room.name ?? _t("common|unnamed_room")).trim() || "Unnamed Room"; const safeDate = formatFullDateNoDayISO(new Date()).replace(/:/g, "-"); // ISO format automatically removes a lot of stuff for us - const safeBrand = sanitizeFilename(brand); + const safeBrand = sanitizeFilename(SdkConfig.get().brand); return `${safeBrand} - ${safeRoomName} - Chat Export - ${safeDate}`; } diff --git a/apps/web/test/unit-tests/utils/exportUtils/JSONExport-test.ts b/apps/web/test/unit-tests/utils/exportUtils/JSONExport-test.ts index 9cc071b4e24..94cfeb08fc7 100644 --- a/apps/web/test/unit-tests/utils/exportUtils/JSONExport-test.ts +++ b/apps/web/test/unit-tests/utils/exportUtils/JSONExport-test.ts @@ -16,7 +16,7 @@ describe("JSONExport", () => { jest.setSystemTime(REPEATABLE_DATE); }); - it("should have a Matrix-branded destination file name", () => { + it("should have an Element-branded destination file name", () => { const roomName = "My / Test / Room: Welcome"; const client = createTestClient(); const stubOptions: IExportOptions = { diff --git a/apps/web/test/unit-tests/utils/exportUtils/PlainTextExport-test.ts b/apps/web/test/unit-tests/utils/exportUtils/PlainTextExport-test.ts index 006e86b3bf6..355e73c30a1 100644 --- a/apps/web/test/unit-tests/utils/exportUtils/PlainTextExport-test.ts +++ b/apps/web/test/unit-tests/utils/exportUtils/PlainTextExport-test.ts @@ -34,7 +34,7 @@ describe("PlainTextExport", () => { stubRoom = mkStubRoom("!myroom:example.org", roomName, client); }); - it("should have a Matrix-branded destination file name", () => { + it("should have an Element-branded destination file name", () => { const exporter = new PlainTextExporter(stubRoom, ExportType.Timeline, stubOptions, () => {}); expect(exporter.destinationFileName).toMatchSnapshot(); diff --git a/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/JSONExport-test.ts.snap b/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/JSONExport-test.ts.snap index b8d2c3ce181..70410b2b248 100644 --- a/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/JSONExport-test.ts.snap +++ b/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/JSONExport-test.ts.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`JSONExport should have a Matrix-branded destination file name 1`] = `"matrix - My Test Room Welcome - Chat Export - 2022-11-17T16-58-32.517Z.json"`; +exports[`JSONExport should have an Element-branded destination file name 1`] = `"Element - My Test Room Welcome - Chat Export - 2022-11-17T16-58-32.517Z.json"`; diff --git a/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/PlainTextExport-test.ts.snap b/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/PlainTextExport-test.ts.snap index de3cff0d551..6c9f5ab29db 100644 --- a/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/PlainTextExport-test.ts.snap +++ b/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/PlainTextExport-test.ts.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`PlainTextExport should have a Matrix-branded destination file name 1`] = `"matrix - My Test Room Welcome - Chat Export - 2022-11-17T16-58-32.517Z.txt"`; +exports[`PlainTextExport should have an Element-branded destination file name 1`] = `"Element - My Test Room Welcome - Chat Export - 2022-11-17T16-58-32.517Z.txt"`; From e52d5f0f99641d34e009b3d11b2d7bccf3a435ca Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 1 Jun 2026 15:14:51 +0100 Subject: [PATCH 7/8] Make cloudflare pages wrangler action happier By disabling root workspace check to workaround https://github.com/cloudflare/wrangler-action/issues/181 --- pnpm-workspace.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 85228dd6448..22d329a0591 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ linkWorkspacePackages: true cleanupUnusedCatalogs: true ignorePatchFailures: false +ignoreWorkspaceRootCheck: true packages: - "apps/*" - "packages/*" From 178e909deaf1b004e18614a7754ce36c4015bc6f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 1 Jun 2026 15:29:18 +0100 Subject: [PATCH 8/8] Add pnpm allowBuilds in order to make wrangler-action happy --- pnpm-workspace.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 22d329a0591..6f025839364 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -59,6 +59,9 @@ allowBuilds: protobufjs: false # Builds optional crypto binding, which we do not need ssh2: false + # The following are to make wrangler-action happy - https://github.com/cloudflare/wrangler-action/issues/436 + sharp: true + workerd: true patchedDependencies: "@vector-im/matrix-wysiwyg": patches/@vector-im__matrix-wysiwyg.patch