diff --git a/.github/workflows/end-to-end-tests-netlify.yaml b/.github/workflows/build-and-test-netlify.yaml similarity index 97% rename from .github/workflows/end-to-end-tests-netlify.yaml rename to .github/workflows/build-and-test-netlify.yaml index c5bca0e192..1b3aa2bd69 100644 --- a/.github/workflows/end-to-end-tests-netlify.yaml +++ b/.github/workflows/build-and-test-netlify.yaml @@ -5,7 +5,7 @@ on: # Privilege escalation necessary to publish to Netlify # 🚨 We must not execute any checked out code here. workflow_run: # zizmor: ignore[dangerous-triggers] - workflows: ["End to End Tests"] + workflows: ["Build & Test"] types: - completed diff --git a/.github/workflows/end-to-end-tests.yaml b/.github/workflows/build-and-test.yaml similarity index 73% rename from .github/workflows/end-to-end-tests.yaml rename to .github/workflows/build-and-test.yaml index 3d72750a1d..e13c537d0e 100644 --- a/.github/workflows/end-to-end-tests.yaml +++ b/.github/workflows/build-and-test.yaml @@ -1,7 +1,13 @@ -# Produce a build of element-web with this version of react-sdk -# and any matching branches of element-web and js-sdk, output it -# as an artifact and run end-to-end tests. -name: End to End Tests +# builds Element Web +# runs Playwright tests against the built Element Web +# builds Element Desktop using the built Element Web +# +# Tries to use a matching js-sdk branch for the build. +# +# Produces a `webapp` artifact +# Produces multiple Desktop artifacts +# Produces multiple Playwright report artifacts +name: Build & Test on: # CRON to run all Projects at 6am UTC schedule: @@ -10,7 +16,8 @@ on: merge_group: types: [checks_requested] push: - branches: [develop, master] + # We do not build on push to develop as the merge_group check handles that + branches: [staging, master] repository_dispatch: types: [element-web-notify] @@ -35,15 +42,15 @@ concurrency: env: # fetchdep.sh needs to know our PR number PR_NUMBER: ${{ github.event.pull_request.number }} - # Use 6 runners in the default case, but 4 when running on a schedule where we run all 5 projects (20 runners total) - NUM_RUNNERS: ${{ github.event_name == 'schedule' && 4 || 6 }} + # Use 4 runners in the default case, but only 1 when running on a schedule where we run all 5 projects + NUM_RUNNERS: ${{ github.event_name == 'schedule' && 1 || 4 }} NX_DEFAULT_OUTPUT_STYLE: stream-without-prefixes permissions: {} # No permissions required jobs: - build: - name: "Build Element-Web" + build_ew: + name: "Build Element Web" runs-on: ubuntu-24.04 if: inputs.skip != true outputs: @@ -94,9 +101,9 @@ jobs: const matrix = Array.from({ length: numRunners }, (_, i) => i + 1); core.setOutput("matrix", JSON.stringify(matrix)); - playwright: - name: "Run Tests [${{ matrix.project }}] ${{ matrix.runner }}/${{ needs.build.outputs.num-runners }}" - needs: build + playwright_ew: + name: "Run Tests [${{ matrix.project }}] ${{ matrix.runner }}/${{ needs.build_ew.outputs.num-runners }}" + needs: build_ew if: inputs.skip != true runs-on: ubuntu-24.04 permissions: @@ -107,7 +114,7 @@ jobs: fail-fast: false matrix: # Run multiple instances in parallel to speed up the tests - runner: ${{ fromJSON(needs.build.outputs.runners-matrix) }} + runner: ${{ fromJSON(needs.build_ew.outputs.runners-matrix) }} project: - Chrome - Firefox @@ -179,29 +186,85 @@ jobs: --project="${{ matrix.project }}" \ ${{ (github.event_name == 'pull_request' && matrix.runAllTests == false ) && '--grep-invert @mergequeue' || '' }} env: - SHARD: ${{ format('{0}/{1}', matrix.runner, needs.build.outputs.num-runners) }} + SHARD: ${{ format('{0}/{1}', matrix.runner, needs.build_ew.outputs.num-runners) }} - name: Upload blob report to GitHub Actions Artifacts if: always() uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: - name: all-blob-reports-${{ matrix.project }}-${{ matrix.runner }} + name: blob-report-${{ matrix.project }}-${{ matrix.runner }} path: apps/web/blob-report retention-days: 1 + if-no-files-found: error downstream-modules: name: Downstream Playwright tests [element-modules] - needs: build + needs: build_ew if: inputs.skip != true && github.event_name == 'merge_group' uses: element-hq/element-modules/.github/workflows/reusable-playwright-tests.yml@main # zizmor: ignore[unpinned-uses] with: webapp-artifact: webapp + prepare_ed: + name: "Prepare Element Desktop" + uses: ./.github/workflows/build_desktop_prepare.yaml + needs: build_ew + if: inputs.skip != true + permissions: + contents: read + with: + config: ${{ (github.event.pull_request.base.ref || github.ref_name) == 'develop' && 'element.io/nightly' || 'element.io/release' }} + version: ${{ (github.event.pull_request.base.ref || github.ref_name) == 'develop' && 'develop' || '' }} + webapp-artifact: webapp + + build_ed_windows: + needs: prepare_ed + name: "Desktop Windows" + uses: ./.github/workflows/build_desktop_windows.yaml + if: inputs.skip != true + strategy: + matrix: + arch: [x64, ia32, arm64] + with: + arch: ${{ matrix.arch }} + blob_report: true + + build_ed_linux: + needs: prepare_ed + name: "Desktop Linux" + uses: ./.github/workflows/build_desktop_linux.yaml + if: inputs.skip != true + strategy: + matrix: + sqlcipher: [system, static] + arch: [amd64, arm64] + runAllTests: + - ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'X-Run-All-Tests') }} + # We ship static sqlcipher builds, so delegate testing the system builds to the merge queue + exclude: + - runAllTests: false + sqlcipher: system + with: + sqlcipher: ${{ matrix.sqlcipher }} + arch: ${{ matrix.arch }} + blob_report: true + + build_ed_macos: + needs: prepare_ed + name: "Desktop macOS" + uses: ./.github/workflows/build_desktop_macos.yaml + if: inputs.skip != true + with: + blob_report: true + complete: name: end-to-end-tests needs: - - playwright + - playwright_ew - downstream-modules + - build_ed_windows + - build_ed_linux + - build_ed_macos if: always() runs-on: ubuntu-24.04 steps: @@ -227,18 +290,20 @@ jobs: if: inputs.skip != true uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - pattern: all-blob-reports-* - path: apps/web/all-blob-reports + pattern: blob-report-* + path: all-blob-reports merge-multiple: true - name: Merge into HTML Report if: inputs.skip != true - working-directory: apps/web - run: pnpm playwright merge-reports --reporter=html,./playwright/flaky-reporter.ts,@element-hq/element-web-playwright-common/lib/stale-screenshot-reporter.js ./all-blob-reports + run: | + pnpm playwright merge-reports \ + --config=playwright-merge.config.ts \ + ./all-blob-reports env: # Only pass creds to the flaky-reporter on main branch runs GITHUB_TOKEN: ${{ github.ref_name == 'develop' && secrets.ELEMENT_BOT_TOKEN || '' }} - PLAYWRIGHT_HTML_TITLE: ${{ case(github.event_name == 'pull_request', format('EW Playwright Report PR-{0}', env.PR_NUMBER), 'EW Playwright Report') }} + PLAYWRIGHT_HTML_TITLE: ${{ case(github.event_name == 'pull_request', format('Playwright Report PR-{0}', env.PR_NUMBER), 'Playwright Report') }} # Upload the HTML report even if one of our reporters fails, this can happen when stale screenshots are detected - name: Upload HTML report @@ -246,7 +311,7 @@ jobs: uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: html-report - path: apps/web/playwright-report + path: playwright-report retention-days: 14 if-no-files-found: error diff --git a/.github/workflows/build_desktop_and_test.yaml b/.github/workflows/build_desktop_and_test.yaml deleted file mode 100644 index 1ae64a87e4..0000000000 --- a/.github/workflows/build_desktop_and_test.yaml +++ /dev/null @@ -1,89 +0,0 @@ -name: Build and Test -on: - pull_request: {} - push: - branches: [develop, staging, master] -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true -permissions: {} # No permissions required -jobs: - fetch: - uses: ./.github/workflows/build_desktop_prepare.yaml - permissions: - contents: read - with: - config: ${{ (github.event.pull_request.base.ref || github.ref_name) == 'develop' && 'element.io/nightly' || 'element.io/release' }} - version: ${{ (github.event.pull_request.base.ref || github.ref_name) == 'develop' && 'develop' || '' }} - branch-matching: true - - windows: - needs: fetch - name: Windows - uses: ./.github/workflows/build_desktop_windows.yaml - strategy: - matrix: - arch: [x64, ia32, arm64] - with: - arch: ${{ matrix.arch }} - blob_report: true - - linux: - needs: fetch - name: "Linux (${{ matrix.arch }}) (sqlcipher: ${{ matrix.sqlcipher }})" - uses: ./.github/workflows/build_desktop_linux.yaml - strategy: - matrix: - sqlcipher: [system, static] - arch: [amd64, arm64] - with: - sqlcipher: ${{ matrix.sqlcipher }} - arch: ${{ matrix.arch }} - blob_report: true - - macos: - needs: fetch - name: macOS - uses: ./.github/workflows/build_desktop_macos.yaml - with: - blob_report: true - - tests-done: - needs: [windows, linux, macos] - runs-on: ubuntu-24.04 - if: ${{ !cancelled() }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - - - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - with: - cache: "pnpm" - node-version: "lts/*" - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Download blob reports from GitHub Actions Artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - pattern: blob-report-* - path: apps/desktop/all-blob-reports - merge-multiple: true - - - name: Merge into HTML Report - working-directory: apps/desktop - run: pnpm playwright merge-reports -c ./playwright.config.ts --reporter=html ./all-blob-reports - - - name: Upload HTML report - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 - with: - name: html-report - path: apps/desktop/playwright-report - retention-days: 14 - - - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') - run: exit 1 diff --git a/.github/workflows/build_desktop_linux.yaml b/.github/workflows/build_desktop_linux.yaml index 938ae9e132..d045cc3d7e 100644 --- a/.github/workflows/build_desktop_linux.yaml +++ b/.github/workflows/build_desktop_linux.yaml @@ -28,7 +28,7 @@ on: type: string required: false description: | - The name of the prepare artifact to use, defaults to 'webapp'. + The name of the prepare artifact to use, defaults to 'desktop-prepare'. The artifact must contain the following: + webapp.asar - the asar archive of the webapp to embed in the desktop app + electronVersion - the version of electron to use for cache keying @@ -38,7 +38,7 @@ on: The artifact can also contain any additional files which will be applied as overrides to the checkout root before building, for example icons in the `build/` directory to override the app icons. - default: "webapp" + default: "desktop-prepare" test: type: boolean required: false @@ -73,20 +73,8 @@ jobs: # https://github.com/matrix-org/seshat/issues/135 runs-on: ${{ inputs.runs-on || (inputs.arch == 'arm64' && 'ubuntu-22.04-arm' || 'ubuntu-22.04') }} env: - HAK_DOCKER_IMAGE: ghcr.io/element-hq/element-web/desktop-build-env + HAK_DOCKER_IMAGE: ghcr.io/element-hq/element-web/desktop-build-env:${{ case(github.event_name == 'push', inputs.ref || github.ref_name, github.event_name == 'release', 'staging', 'develop') }} steps: - - name: Resolve docker image tag for push - if: github.event_name == 'push' - run: echo "HAK_DOCKER_IMAGE=$HAK_DOCKER_IMAGE:$REF" >> $GITHUB_ENV - env: - REF: ${{ inputs.ref || github.ref_name }} - - name: Resolve docker image tag for release - if: github.event_name == 'release' - run: echo "HAK_DOCKER_IMAGE=$HAK_DOCKER_IMAGE:staging" >> $GITHUB_ENV - - name: Resolve docker image tag for other triggers - if: github.event_name != 'push' && github.event_name != 'release' - run: echo "HAK_DOCKER_IMAGE=$HAK_DOCKER_IMAGE:develop" >> $GITHUB_ENV - - uses: nbucic/variable-mapper@0673f6891a0619ba7c002ecfed0f9f4f39017b6f id: config with: @@ -95,11 +83,9 @@ jobs: map: | { "amd64": { - "target": "x86_64-unknown-linux-gnu", "arch": "x86-64" }, "arm64": { - "target": "aarch64-unknown-linux-gnu", "arch": "aarch64", "build-args": "--arm64" } @@ -120,7 +106,7 @@ jobs: id: cache uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 with: - key: ${{ runner.os }}-${{ github.ref_name }}-${{ inputs.sqlcipher }}-${{ inputs.arch }}-${{ hashFiles('hakHash', 'electronVersion', 'dockerbuild/*') }} + key: ${{ runner.os }}-${{ github.ref_name }}-${{ inputs.sqlcipher }}-${{ inputs.arch }}-${{ hashFiles('apps/desktop/hakHash', 'apps/desktop/electronVersion', 'apps/desktop/dockerbuild/*') }} path: | apps/desktop/.hak @@ -135,7 +121,7 @@ jobs: - name: Install Deps working-directory: apps/desktop - run: pnpm install --frozen-lockfile + run: "pnpm install --frozen-lockfile --filter element-desktop" - name: "Get modified files" id: changed_files diff --git a/.github/workflows/build_desktop_macos.yaml b/.github/workflows/build_desktop_macos.yaml index c4f828a092..bc5455b5a4 100644 --- a/.github/workflows/build_desktop_macos.yaml +++ b/.github/workflows/build_desktop_macos.yaml @@ -37,7 +37,7 @@ on: type: string required: false description: | - The name of the prepare artifact to use, defaults to 'webapp'. + The name of the prepare artifact to use, defaults to 'desktop-prepare'. The artifact must contain the following: + webapp.asar - the asar archive of the webapp to embed in the desktop app + electronVersion - the version of electron to use for cache keying @@ -46,7 +46,7 @@ on: The artifact can also contain any additional files which will be applied as overrides to the checkout root before building, for example icons in the `build/` directory to override the app icons. - default: "webapp" + default: "desktop-prepare" test: type: boolean required: false @@ -92,7 +92,7 @@ jobs: id: cache uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 with: - key: ${{ runner.os }}-${{ hashFiles('hakHash', 'electronVersion') }} + key: ${{ runner.os }}-${{ hashFiles('apps/desktop/hakHash', 'apps/desktop/electronVersion') }} path: | apps/desktop/.hak @@ -121,7 +121,7 @@ jobs: - name: Install Deps working-directory: apps/desktop - run: "pnpm install --frozen-lockfile" + run: "pnpm install --frozen-lockfile --filter element-desktop" - name: Build Natives if: steps.cache.outputs.cache-hit != 'true' diff --git a/.github/workflows/build_desktop_prepare.yaml b/.github/workflows/build_desktop_prepare.yaml index 517b865ab1..fe6c5a2624 100644 --- a/.github/workflows/build_desktop_prepare.yaml +++ b/.github/workflows/build_desktop_prepare.yaml @@ -20,11 +20,10 @@ on: required: false default: false description: "Whether the build should be deployed to production" - branch-matching: - type: boolean + webapp-artifact: + type: string required: false - default: false - description: "Whether the branch name should be matched to find the element-web commit" + description: "Name of the webapp artifact that should be used, will fetch a relevant build if omitted" secrets: # Required if `nightly` is set CF_R2_ACCESS_KEY_ID: @@ -57,6 +56,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false + repository: element-hq/element-web - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 @@ -66,28 +66,26 @@ jobs: - name: Install Deps working-directory: apps/desktop - run: "pnpm install --frozen-lockfile" + run: "pnpm install --frozen-lockfile --filter element-desktop" - - name: Fetch Element Web (matching branch) - id: branch-matching - if: inputs.branch-matching + - name: Fetch Element Web (from artifact) + if: inputs.webapp-artifact != '' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: ${{ inputs.webapp-artifact }} + path: apps/desktop/webapp + + - name: Build webapp.asar (from artifact) + if: inputs.webapp-artifact != '' working-directory: apps/desktop - continue-on-error: true run: | - scripts/branch-match.sh - cp "$CONFIG_DIR/config.json" element-web/ - pnpm --cwd element-web install --frozen-lockfile - pnpm --cwd element-web run build - mv element-web/webapp . + cp -f "$CONFIG_DIR/config.json" webapp/config.json pnpm run asar-webapp env: - # These must be set for branch-match.sh to get the right branch - REPOSITORY: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} CONFIG_DIR: ${{ inputs.config }} - name: Fetch Element Web (${{ inputs.version }}) - if: steps.branch-matching.outcome == 'failure' || steps.branch-matching.outcome == 'skipped' + if: inputs.webapp-artifact == '' working-directory: apps/desktop run: pnpm run fetch --noverify -d ${CONFIG} ${VERSION} env: @@ -189,7 +187,7 @@ jobs: - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: - name: webapp + name: desktop-prepare retention-days: 1 path: | apps/desktop/webapp.asar diff --git a/.github/workflows/build_desktop_test.yaml b/.github/workflows/build_desktop_test.yaml index 09fd9e46e9..ba347fa5b3 100644 --- a/.github/workflows/build_desktop_test.yaml +++ b/.github/workflows/build_desktop_test.yaml @@ -48,8 +48,7 @@ jobs: cache: "pnpm" - name: Install Deps - working-directory: apps/desktop - run: "pnpm install --frozen-lockfile" + run: "pnpm install --frozen-lockfile --filter element-desktop" - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: @@ -85,12 +84,19 @@ jobs: EXECUTABLE: ${{ steps.executable.outputs.path }} - name: Run tests - uses: coactions/setup-xvfb@6b00cf1889f4e1d5a48635647013c0508128ee1a timeout-minutes: 20 - with: - run: pnpm -C apps/desktop test --project=${{ inputs.project }} ${{ runner.os != 'Linux' && '--ignore-snapshots' || '' }} ${{ inputs.blob_report == false && '--reporter=html' || '' }} ${{ inputs.args }} + shell: bash + working-directory: apps/desktop + run: | + $PREFIX pnpm playwright test \ + ${{ runner.os != 'Linux' && '--ignore-snapshots' || '' }} \ + ${{ inputs.blob_report == false && '--reporter=html' || '' }} \ + $ARGS env: + PREFIX: ${{ runner.os == 'Linux' && 'xvfb-run' || '' }} + PW_TAG: ${{ inputs.project }} ELEMENT_DESKTOP_EXECUTABLE: ${{ steps.executable.outputs.path }} + ARGS: ${{ inputs.args }} - name: Upload blob report if: always() && inputs.blob_report @@ -99,6 +105,7 @@ jobs: name: blob-report-${{ inputs.artifact }} path: apps/desktop/blob-report retention-days: 1 + if-no-files-found: error - name: Upload HTML report if: always() && inputs.blob_report == false @@ -107,3 +114,4 @@ jobs: name: ${{ inputs.artifact }}-test path: apps/desktop/playwright-report retention-days: 14 + if-no-files-found: error diff --git a/.github/workflows/build_desktop_windows.yaml b/.github/workflows/build_desktop_windows.yaml index 37fc8679aa..ebad3763f0 100644 --- a/.github/workflows/build_desktop_windows.yaml +++ b/.github/workflows/build_desktop_windows.yaml @@ -42,7 +42,7 @@ on: type: string required: false description: | - The name of the prepare artifact to use, defaults to 'webapp'. + The name of the prepare artifact to use, defaults to 'desktop-prepare'. The artifact must contain the following: + webapp.asar - the asar archive of the webapp to embed in the desktop app + electronVersion - the version of electron to use for cache keying @@ -52,7 +52,7 @@ on: The artifact can also contain any additional files which will be applied as overrides to the checkout root before building, for example icons in the `build/` directory to override the app icons. - default: "webapp" + default: "desktop-prepare" test: type: boolean required: false @@ -123,7 +123,7 @@ jobs: id: cache uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 with: - key: ${{ runner.os }}-${{ inputs.arch }}-${{ hashFiles('hakHash', 'electronVersion') }} + key: ${{ runner.os }}-${{ inputs.arch }}-${{ hashFiles('apps/desktop/hakHash', 'apps/desktop/electronVersion') }} path: | apps/desktop/.hak @@ -160,7 +160,7 @@ jobs: - name: Install Deps working-directory: apps/desktop - run: "pnpm install --frozen-lockfile" + run: "pnpm install --frozen-lockfile --filter element-desktop" - name: Insert config snippet if: steps.config.outputs.extra_config != '' diff --git a/apps/desktop/dockerbuild/Dockerfile b/apps/desktop/dockerbuild/Dockerfile index 9cdd24550d..0acea0e60d 100644 --- a/apps/desktop/dockerbuild/Dockerfile +++ b/apps/desktop/dockerbuild/Dockerfile @@ -1,7 +1,7 @@ # Docker image to facilitate building Element Desktop's native bits using a glibc version (2.31) # with broader compatibility, down to Debian bullseye & Ubuntu focal. -FROM rust:bullseye@sha256:16950191527a4cb9e0762d9d48b705a6315158e4035e64f7a93ce8656a1b053c +FROM rust:bullseye@sha256:bc19574c121fe10c1bc68fc2b1ea9b420d87d047a0c50fb1622b282199700cee ENV DEBIAN_FRONTEND=noninteractive diff --git a/apps/desktop/hak/matrix-seshat/check.ts b/apps/desktop/hak/matrix-seshat/check.ts index a62a239c80..e207451832 100644 --- a/apps/desktop/hak/matrix-seshat/check.ts +++ b/apps/desktop/hak/matrix-seshat/check.ts @@ -14,10 +14,7 @@ import type { Tool } from "../../scripts/hak/hakEnv.ts"; import type { DependencyInfo } from "../../scripts/hak/dep.ts"; export default async function (hakEnv: HakEnv, moduleInfo: DependencyInfo): Promise { - const tools: Tool[] = [ - ["rustc", "--version"], - ["python", "--version"], // node-gyp uses python for reasons beyond comprehension - ]; + const tools: Tool[] = [["rustc", "--version"]]; if (hakEnv.isWin()) { tools.push(["perl", "--version"]); // for openssl configure tools.push(["nasm", "-v"]); // for openssl building @@ -28,6 +25,14 @@ export default async function (hakEnv: HakEnv, moduleInfo: DependencyInfo): Prom } await hakEnv.checkTools(tools); + try { + // node-gyp uses python for reasons beyond comprehension + await hakEnv.checkTools([["python", "--version"]]); + } catch { + // try python3 too + await hakEnv.checkTools([["python3", "--version"]]); + } + // Ensure Rust target exists (nb. we avoid depending on rustup) await new Promise((resolve, reject) => { const rustc = childProcess.execFile( diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e43c10023e..4849ad9d69 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -71,7 +71,7 @@ "@babel/core": "^7.18.10", "@babel/preset-env": "^7.18.10", "@babel/preset-typescript": "^7.18.6", - "@electron/asar": "4.1.0", + "@electron/asar": "4.1.2", "@playwright/test": "catalog:", "@stylistic/eslint-plugin": "^5.0.0", "@types/auto-launch": "^5.0.1", @@ -84,7 +84,7 @@ "app-builder-lib": "26.8.2", "chokidar": "^5.0.0", "detect-libc": "^2.0.0", - "electron": "41.0.3", + "electron": "41.1.0", "electron-builder": "26.8.2", "electron-builder-squirrel-windows": "26.8.2", "electron-devtools-installer": "^4.0.0", @@ -107,5 +107,5 @@ "hakDependencies": { "matrix-seshat": "^4.0.1" }, - "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be" + "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319" } diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts index 6b3e76347f..bb8e2b52b4 100644 --- a/apps/desktop/playwright.config.ts +++ b/apps/desktop/playwright.config.ts @@ -8,25 +8,9 @@ Please see LICENSE files in the repository root for full details. import { defineConfig } from "@playwright/test"; -const projects = [ - "macos", - "win-x64", - "win-ia32", - "win-arm64", - "linux-amd64-sqlcipher-system", - "linux-amd64-sqlcipher-static", - "linux-arm64-sqlcipher-system", - "linux-arm64-sqlcipher-static", -]; - export default defineConfig({ - // Allows the GitHub action to specify a project name (OS + arch) for the combined report to make sense - // workaround for https://github.com/microsoft/playwright/issues/33521 - projects: process.env.CI - ? projects.map((name) => ({ - name, - })) - : undefined, + projects: [{ name: "Desktop" }], + tag: process.env.PW_TAG ? `@${process.env.PW_TAG}` : undefined, use: { viewport: { width: 1280, height: 720 }, video: "retain-on-failure", diff --git a/apps/desktop/project.json b/apps/desktop/project.json index bf186a1386..cb43a99d61 100644 --- a/apps/desktop/project.json +++ b/apps/desktop/project.json @@ -1,6 +1,7 @@ { "$schema": "../../node_modules/nx/schemas/project-schema.json", - "projectType": "app", + "projectType": "application", + "implicitDependencies": ["element-web"], "root": "apps/desktop", "targets": { "docker:build": { diff --git a/apps/desktop/scripts/branch-match.sh b/apps/desktop/scripts/branch-match.sh deleted file mode 100755 index c42073dfa4..0000000000 --- a/apps/desktop/scripts/branch-match.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash - -# Script for downloading a branch of element-web matching the branch a PR is contributed from - -set -x - -deforg="element-hq" -defrepo="element-web" - -# The PR_NUMBER variable must be set explicitly. -default_org_repo=${GITHUB_REPOSITORY:-"$deforg/$defrepo"} -PR_ORG=${PR_ORG:-${default_org_repo%%/*}} -PR_REPO=${PR_REPO:-${default_org_repo##*/}} - -# A function that clones a branch of a repo based on the org, repo and branch -clone() { - org=$1 - repo=$2 - branch=$3 - if [ -n "$branch" ] - then - echo "Trying to use $org/$repo#$branch" - # Disable auth prompts: https://serverfault.com/a/665959 - GIT_TERMINAL_PROMPT=0 git clone https://github.com/$org/$repo.git $repo --branch "$branch" --depth 1 && exit 0 - fi -} - -echo "Getting info about a PR with number $PR_NUMBER" -apiEndpoint="https://api.github.com/repos/$PR_ORG/$PR_REPO/pulls/$PR_NUMBER" -head=$(curl "$apiEndpoint" | jq -r '.head.label') - -# for forks, $head will be in the format "fork:branch", so we split it by ":" -# into an array. On non-forks, this has the effect of splitting into a single -# element array given ":" shouldn't appear in the head - it'll just be the -# branch name. Based on the results, we clone. -BRANCH_ARRAY=(${head//:/ }) -TRY_ORG=$deforg -TRY_BRANCH=${BRANCH_ARRAY[0]} -if [[ "$head" == *":"* ]]; then - # ... but only match that fork if it's a real fork - if [ "${BRANCH_ARRAY[0]}" != "$PR_ORG" ]; then - TRY_ORG=${BRANCH_ARRAY[0]} - fi - TRY_BRANCH=${BRANCH_ARRAY[1]} -fi -clone "$TRY_ORG" "$defrepo" "$TRY_BRANCH" - -exit 1 diff --git a/apps/web/.stylelintrc.cjs b/apps/web/.stylelintrc.cjs index 57c39e8c05..2ccb2abf75 100644 --- a/apps/web/.stylelintrc.cjs +++ b/apps/web/.stylelintrc.cjs @@ -56,6 +56,7 @@ module.exports = { { from: "res/css/views/rooms/_EditMessageComposer.pcss", type: "css" }, { from: "res/css/views/right_panel/_BaseCard.pcss", type: "css" }, { from: "res/css/views/messages/_MessageActionBar.pcss", type: "css" }, + { from: "res/css/views/messages/_ThreadActionBar.pcss", type: "css" }, { from: "res/css/views/voip/LegacyCallView/_LegacyCallViewButtons.pcss", type: "css" }, { from: "res/css/views/elements/_ToggleSwitch.pcss", type: "css" }, { from: "res/css/views/settings/tabs/_SettingsTab.pcss", type: "css" }, diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 7cc187024b..ca8d04950d 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -2,7 +2,7 @@ # Context must be the root of the monorepo # Builder -FROM --platform=$BUILDPLATFORM node:24-bullseye@sha256:4bfbd78e049926e4ca595c1798810691ca7bb5aedd829ffd8a78b2ab30689810 AS builder +FROM --platform=$BUILDPLATFORM node:24-bullseye@sha256:27e462f5db2402700867dfa8ec35e3a68b127fdf61b505db0dd6ab98c38284bb AS builder # Support custom branch of the js-sdk. This also helps us build images of element-web develop. ARG USE_CUSTOM_SDKS=false @@ -25,7 +25,7 @@ RUN --mount=type=bind,source=.git,target=/src/.git /src/scripts/docker-package.s RUN cp /src/apps/web/config.sample.json /src/apps/web/webapp/config.json # App -FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:4011c42f28e9b54c86b52211598dbc6bcaa520311ddd55f211587cdd71f88a9c +FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:b5831ee7f7aa827cbae87df4a30a642f62c747d8525f5674365389f3adab278d # Need root user to install packages & manipulate the usr directory USER root diff --git a/apps/web/package.json b/apps/web/package.json index 655c44ab11..0428d9a51b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -101,7 +101,7 @@ "react-transition-group": "^4.4.1", "rfc4648": "^1.4.0", "sanitize-filename": "^1.6.3", - "sanitize-html": "2.17.1", + "sanitize-html": "2.17.2", "tar-js": "^0.3.0", "ua-parser-js": "1.0.40", "uuid": "^13.0.0", @@ -203,7 +203,7 @@ "jest-raw-loader": "^1.0.1", "jsqr": "^1.4.0", "matrix-web-i18n": "catalog:", - "mini-css-extract-plugin": "2.10.1", + "mini-css-extract-plugin": "2.10.2", "modernizr": "^3.12.0", "playwright-core": "catalog:", "postcss": "8.5.8", @@ -246,6 +246,6 @@ "engines": { "node": ">=22.18" }, - "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be", + "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319", "private": true } diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts new file mode 100644 index 0000000000..fdc3513792 --- /dev/null +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts @@ -0,0 +1,260 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type Locator, type Page } from "@playwright/test"; + +import { expect, test } from "../../../element-web-test"; + +test.describe("Room list sections", () => { + test.use({ + displayName: "Alice", + labsFlags: ["feature_new_room_list", "feature_room_list_sections"], + botCreateOpts: { + displayName: "BotBob", + autoAcceptInvites: true, + }, + }); + + /** + * Get the room list + * @param page + */ + function getRoomList(page: Page): Locator { + return page.getByTestId("room-list"); + } + + /** + * Get the primary filters + * @param page + */ + function getPrimaryFilters(page: Page): Locator { + return page.getByTestId("primary-filters"); + } + + /** + * Get a section header toggle button by section name + * @param page + * @param sectionName The display name of the section (e.g. "Favourites", "Chats", "Low Priority") + */ + function getSectionHeader(page: Page, sectionName: string): Locator { + return getRoomList(page).getByRole("gridcell", { name: `Toggle ${sectionName} section` }); + } + + test.beforeEach(async ({ page, app, user }) => { + // The notification toast is displayed above the search section + await app.closeNotificationToast(); + + // focus the user menu to avoid to have hover decoration + await page.getByRole("button", { name: "User menu" }).focus(); + }); + + test.describe("Section rendering", () => { + test.beforeEach(async ({ app, user }) => { + // Create regular rooms + for (let i = 0; i < 3; i++) { + await app.client.createRoom({ name: `room${i}` }); + } + }); + + test("should render sections with correct rooms in each", { tag: "@screenshot" }, async ({ page, app }) => { + // Create a favourite room + const favouriteId = await app.client.createRoom({ name: "favourite room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId); + + // Create a low priority room + const lowPrioId = await app.client.createRoom({ name: "low prio room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.lowpriority"); + }, lowPrioId); + + const roomList = getRoomList(page); + + // All three section headers should be visible + await expect(getSectionHeader(page, "Favourites")).toBeVisible(); + await expect(getSectionHeader(page, "Chats")).toBeVisible(); + await expect(getSectionHeader(page, "Low Priority")).toBeVisible(); + + // Ensure all rooms are visible + await expect(roomList.getByRole("row", { name: "Open room favourite room" })).toBeVisible(); + await expect(roomList.getByRole("row", { name: "Open room low prio room" })).toBeVisible(); + await expect(roomList.getByRole("row", { name: "Open room room0" })).toBeVisible(); + + await expect(roomList).toMatchScreenshot("room-list-sections.png"); + }); + + test("should only show non-empty sections", async ({ page, app }) => { + // No low priority rooms created, only regular and favourite rooms + const favouriteId = await app.client.createRoom({ name: "favourite room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId); + + // Chats and Favourites sections should still be visible + await expect(getSectionHeader(page, "Chats")).toBeVisible(); + await expect(getSectionHeader(page, "Favourites")).toBeVisible(); + // Low Priority sections should not be visible + await expect(getSectionHeader(page, "Low Priority")).not.toBeVisible(); + }); + + test("should render a flat list when there is only rooms in Chats section", async ({ page, app }) => { + // All sections should not be visible + await expect(getSectionHeader(page, "Chats")).not.toBeVisible(); + await expect(getSectionHeader(page, "Favourites")).not.toBeVisible(); + await expect(getSectionHeader(page, "Low Priority")).not.toBeVisible(); + // It should be a flat list (using listbox a11y role) + await expect(page.getByRole("listbox", { name: "Room list", exact: true })).toBeVisible(); + await expect(getRoomList(page).getByRole("option", { name: "Open room room0" })).toBeVisible(); + }); + }); + + test.describe("Section collapse and expand", () => { + [ + { section: "Favourites", roomName: "favourite room", tag: "m.favourite" }, + { section: "Low Priority", roomName: "low prio room", tag: "m.lowpriority" }, + ].forEach(({ section, roomName, tag }) => { + test(`should collapse and expand the ${section} section`, async ({ page, app }) => { + const roomId = await app.client.createRoom({ name: roomName }); + if (tag) { + await app.client.evaluate( + async (client, { roomId, tag }) => { + await client.setRoomTag(roomId, tag); + }, + { roomId, tag }, + ); + } + + const roomList = getRoomList(page); + const sectionHeader = getSectionHeader(page, section); + + // The room should be visible + await expect(roomList.getByRole("row", { name: `Open room ${roomName}` })).toBeVisible(); + + // Collapse the section + await sectionHeader.click(); + + // The room should no longer be visible + await expect(roomList.getByRole("row", { name: `Open room ${roomName}` })).not.toBeVisible(); + + // The section header should still be visible + await expect(sectionHeader).toBeVisible(); + + // Expand the section again + await sectionHeader.click(); + + // The room should be visible again + await expect(roomList.getByRole("row", { name: `Open room ${roomName}` })).toBeVisible(); + }); + }); + + test("should render collapsed section", { tag: "@screenshot" }, async ({ page, app }) => { + const favouriteId = await app.client.createRoom({ name: "favourite room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId); + + await app.client.createRoom({ name: "regular room" }); + + const roomList = getRoomList(page); + + // Collapse the Favourites section + await getSectionHeader(page, "Favourites").click(); + + // Verify favourite room is hidden but regular room is still visible + await expect(roomList.getByRole("row", { name: "Open room favourite room" })).not.toBeVisible(); + await expect(roomList.getByRole("row", { name: "Open room regular room" })).toBeVisible(); + + await expect(roomList).toMatchScreenshot("room-list-sections-collapsed.png"); + }); + }); + + test.describe("Rooms placement in sections", () => { + test("should move a room between sections when tags change", async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + + const roomList = getRoomList(page); + + // Flat list because there is only rooms in the Chats section + let roomItem = roomList.getByRole("option", { name: "Open room my room" }); + await expect(roomItem).toBeVisible(); + + // Favourite the room via context menu + await roomItem.click({ button: "right" }); + await page.getByRole("menuitemcheckbox", { name: "Favourited" }).click(); + + // The Favourites section header should now be visible and the room should be under it + await expect(getSectionHeader(page, "Favourites")).toBeVisible(); + roomItem = roomList.getByRole("row", { name: "Open room my room" }); + await expect(roomItem).toBeVisible(); + + // Unfavourite the room + await roomItem.hover(); + await roomItem.getByRole("button", { name: "More Options" }).click(); + await page.getByRole("menuitemcheckbox", { name: "Favourited" }).click(); + + // Mark the room as low priority via context menu + roomItem = roomList.getByRole("option", { name: "Open room my room" }); + await roomItem.click({ button: "right" }); + await page.getByRole("menuitemcheckbox", { name: "Low priority" }).click(); + + // The Low Priority section header should now be visible and the room should be under it + await expect(getSectionHeader(page, "Low Priority")).toBeVisible(); + roomItem = roomList.getByRole("row", { name: "Open room my room" }); + await expect(roomItem).toBeVisible(); + }); + }); + + test.describe("Sections and filters interaction", () => { + test("should not show Favourite and Low Priority filters when sections are enabled", async ({ page, app }) => { + const primaryFilters = getPrimaryFilters(page); + + // Expand the filter list to see all filters + const expandButton = primaryFilters.getByRole("button", { name: "Expand filter list" }); + await expandButton.click(); + + // Favourite and Low Priority filters should NOT be visible since sections handle them + await expect(primaryFilters.getByRole("option", { name: "Favourite" })).not.toBeVisible(); + + // Other filters should still be present + await expect(primaryFilters.getByRole("option", { name: "People" })).toBeVisible(); + await expect(primaryFilters.getByRole("option", { name: "Rooms" })).toBeVisible(); + await expect(primaryFilters.getByRole("option", { name: "Unread" })).toBeVisible(); + }); + + test("should maintain sections when a filter is applied", async ({ page, app, bot }) => { + // Create a favourite room with unread messages + const favouriteId = await app.client.createRoom({ name: "fav with unread" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId); + await app.client.inviteUser(favouriteId, bot.credentials.userId); + await bot.joinRoom(favouriteId); + await bot.sendMessage(favouriteId, "Hello from favourite!"); + + // Create a regular room with unread messages + const regularId = await app.client.createRoom({ name: "regular with unread" }); + await app.client.inviteUser(regularId, bot.credentials.userId); + await bot.joinRoom(regularId); + await bot.sendMessage(regularId, "Hello from regular!"); + + // Create a room without unread + await app.client.createRoom({ name: "no unread room" }); + + const roomList = getRoomList(page); + const primaryFilters = getPrimaryFilters(page); + + // Apply the Unread filter + await primaryFilters.getByRole("option", { name: "Unread" }).click(); + + // Only rooms with unreads should be visible + await expect(roomList.getByRole("row", { name: "fav with unread" })).toBeVisible(); + await expect(roomList.getByRole("row", { name: "regular with unread" })).toBeVisible(); + await expect(roomList.getByRole("row", { name: "no unread room" })).not.toBeVisible(); + }); + }); +}); diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-collapsed-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-collapsed-linux.png new file mode 100644 index 0000000000..6ebcef8532 Binary files /dev/null and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-collapsed-linux.png differ diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-linux.png new file mode 100644 index 0000000000..bd61342d60 Binary files /dev/null and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-linux.png differ diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index 9435e53b6f..b4f49e767f 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -232,13 +232,13 @@ @import "./views/messages/_MPollBody.pcss"; @import "./views/messages/_MStickerBody.pcss"; @import "./views/messages/_MTextBody.pcss"; -@import "./views/messages/_MVideoBody.pcss"; @import "./views/messages/_MediaBody.pcss"; @import "./views/messages/_MessageActionBar.pcss"; @import "./views/messages/_MjolnirBody.pcss"; @import "./views/messages/_ReactionsRow.pcss"; @import "./views/messages/_RoomAvatarEvent.pcss"; @import "./views/messages/_TextualEvent.pcss"; +@import "./views/messages/_ThreadActionBar.pcss"; @import "./views/messages/_UnknownBody.pcss"; @import "./views/messages/_ViewSourceEvent.pcss"; @import "./views/messages/_common_CryptoEvent.pcss"; diff --git a/apps/web/res/css/views/dialogs/_MessageEditHistoryDialog.pcss b/apps/web/res/css/views/dialogs/_MessageEditHistoryDialog.pcss index 5d06545ae8..d14bb3ca60 100644 --- a/apps/web/res/css/views/dialogs/_MessageEditHistoryDialog.pcss +++ b/apps/web/res/css/views/dialogs/_MessageEditHistoryDialog.pcss @@ -82,13 +82,11 @@ Please see LICENSE files in the repository root for full details. } } - .mx_MessageActionBar .mx_AccessibleButton { - display: flex; - align-items: center; + .mx_HistoryActionBar { + border-radius: 0 !important; + } - padding-inline-start: $spacing-8; - padding-inline-end: $spacing-8; - - font-size: $font-15px; + .mx_HistoryActionBar [data-presentation="label"] { + line-height: 24px !important; } } diff --git a/apps/web/res/css/views/messages/_MVideoBody.pcss b/apps/web/res/css/views/messages/_MVideoBody.pcss deleted file mode 100644 index 0727a8dc44..0000000000 --- a/apps/web/res/css/views/messages/_MVideoBody.pcss +++ /dev/null @@ -1,21 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2020, 2021 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -span.mx_MVideoBody { - overflow: hidden; - - .mx_MVideoBody_container { - border-radius: var(--MBody-border-radius); - overflow: hidden; - - video { - height: 100%; - width: 100%; - } - } -} diff --git a/apps/web/res/css/views/messages/_MessageActionBar.pcss b/apps/web/res/css/views/messages/_MessageActionBar.pcss index 31a83d3feb..d078632ce2 100644 --- a/apps/web/res/css/views/messages/_MessageActionBar.pcss +++ b/apps/web/res/css/views/messages/_MessageActionBar.pcss @@ -9,19 +9,8 @@ Please see LICENSE files in the repository root for full details. .mx_MessageActionBar { --MessageActionBar-size-button: 28px; --MessageActionBar-size-margin: 3px; - --MessageActionBar-item-hover-background: var(--cpd-color-bg-subtle-secondary); - --MessageActionBar-item-hover-borderRadius: 6px; - --MessageActionBar-item-hover-zIndex: 1; position: absolute; - visibility: hidden; - cursor: pointer; - display: flex; - gap: var(--cpd-space-0-5x); - line-height: $font-24px; - border-radius: 8px; - background: $background; - border: var(--cpd-border-width-1) solid var(--cpd-color-border-disabled); top: calc( -1 * ( @@ -75,51 +64,4 @@ Please see LICENSE files in the repository root for full details. left: 0; } } - - > * { - white-space: nowrap; - display: inline-block; - position: relative; - margin: var(--MessageActionBar-size-margin); - - &:hover { - background: var(--MessageActionBar-item-hover-background); - border-radius: var(--MessageActionBar-item-hover-borderRadius); - z-index: var(--MessageActionBar-item-hover-zIndex); - } - } - - .mx_MessageActionBar_iconButton { - --MessageActionBar-icon-size: 20px; - width: var(--MessageActionBar-size-button); - height: var(--MessageActionBar-size-button); - color: var(--cpd-color-icon-secondary); - display: flex; - align-items: center; - justify-content: center; - - svg { - height: var(--MessageActionBar-icon-size); - width: var(--MessageActionBar-icon-size); - flex: 0 0 var(--MessageActionBar-icon-size); - } - - &:disabled, - &[disabled] { - cursor: not-allowed; - opacity: 0.75; - } - - &:hover { - color: var(--cpd-color-icon-primary); - } - - &.mx_MessageActionBar_downloadButton { - &.mx_MessageActionBar_downloadSpinnerButton { - svg { - display: none; /* hide the download icon */ - } - } - } - } } diff --git a/apps/web/res/css/views/messages/_ThreadActionBar.pcss b/apps/web/res/css/views/messages/_ThreadActionBar.pcss new file mode 100644 index 0000000000..e6f71fc024 --- /dev/null +++ b/apps/web/res/css/views/messages/_ThreadActionBar.pcss @@ -0,0 +1,58 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.mx_ThreadActionBar { + position: absolute; + visibility: hidden; + top: calc(-1 * (28px + 2 * (3px + var(--cpd-border-width-1)))); + right: 8px; + user-select: none; + /* Ensure the action bar appears above other things like the read marker */ + /* and sender avatar (for small screens) */ + z-index: 10; + + /* Adds a previous event safe area so that you can't accidentally hover the */ + /* previous event while trying to mouse into the action bar or from the */ + /* react button to its tooltip. */ + &::before { + content: ""; + position: absolute; + /* tooltip safe mousing area + tooltip overhang + */ + /* action bar + action bar offset from event */ + width: calc(10px + 48px + 100% + 8px); + /* safe area + action bar */ + height: calc(20px + 100%); + top: -12px; + left: -58px; + z-index: -1; + cursor: initial; + + /* stylelint-disable-next-line max-line-length */ + .mx_GenericEventListSummary[data-layout="bubble"] + .mx_GenericEventListSummary_toggle + ~ .mx_GenericEventListSummary_unstyledList + .mx_EventTile_info:first-of-type + & { + /* improve clickability of "collapse" link button on bubble layout by reducing width and height values */ + /* mx_GenericEventListSummary_toggle ~: to apply rules to action bar when "collapse" button is available */ + /* mx_EventTile_info:first-of-type: to apply rules to the info event tile just under "collapse" button */ + /* TODO: use a new class name instead */ + width: 100%; + height: 100%; + top: 0; + left: 0; + } + + .mx_EventTile_info .mx_ViewSourceEvent ~ & { + /* improve clickability of view source event toggle button by removing vertical safe area */ + width: 100%; + height: 100%; + top: 0; + left: 0; + } + } +} diff --git a/apps/web/res/css/views/rooms/_EventTile.pcss b/apps/web/res/css/views/rooms/_EventTile.pcss index 59bb2d23d0..5bea427961 100644 --- a/apps/web/res/css/views/rooms/_EventTile.pcss +++ b/apps/web/res/css/views/rooms/_EventTile.pcss @@ -938,10 +938,10 @@ $left-gutter: 64px; } } -.mx_EventTile:hover .mx_MessageActionBar, -.mx_EventTile.mx_EventTile_actionBarFocused .mx_MessageActionBar, -[data-whatinput="keyboard"] .mx_EventTile:focus-within .mx_MessageActionBar, -.mx_EventTile:focus-visible:focus-within .mx_MessageActionBar { +.mx_EventTile:hover .mx_ThreadActionBar, +.mx_EventTile.mx_EventTile_actionBarFocused .mx_ThreadActionBar, +[data-whatinput="keyboard"] .mx_EventTile:focus-within .mx_ThreadActionBar, +.mx_EventTile:focus-visible:focus-within .mx_ThreadActionBar { visibility: visible; } diff --git a/apps/web/src/components/views/messages/DownloadActionButton.tsx b/apps/web/src/components/views/messages/DownloadActionButton.tsx deleted file mode 100644 index af6ad8cc1d..0000000000 --- a/apps/web/src/components/views/messages/DownloadActionButton.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2021 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; -import React, { type ReactElement, useMemo } from "react"; -import classNames from "classnames"; -import { DownloadIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; - -import { type MediaEventHelper } from "../../../utils/MediaEventHelper"; -import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex"; -import Spinner from "../elements/Spinner"; -import { _t } from "../../../languageHandler"; -import { useDownloadMedia } from "../../../hooks/useDownloadMedia"; - -interface IProps { - mxEvent: MatrixEvent; - - // XXX: It can take a cycle or two for the MessageActionBar to have all the props/setup - // required to get us a MediaEventHelper, so we use a getter function instead to prod for - // one. - mediaEventHelperGet: () => MediaEventHelper | undefined; -} - -function useButtonTitle(loading: boolean, isEncrypted: boolean): string { - if (!loading) return _t("action|download"); - - return isEncrypted ? _t("timeline|download_action_decrypting") : _t("timeline|download_action_downloading"); -} - -export default function DownloadActionButton({ mxEvent, mediaEventHelperGet }: IProps): ReactElement | null { - const mediaEventHelper = useMemo(() => mediaEventHelperGet(), [mediaEventHelperGet]); - const downloadUrl = mediaEventHelper?.media.srcHttp ?? ""; - const fileName = mediaEventHelper?.fileName; - - const { download, loading, canDownload } = useDownloadMedia(downloadUrl, fileName, mxEvent); - - const buttonTitle = useButtonTitle(loading, mediaEventHelper?.media.isEncrypted ?? false); - - if (!canDownload) return null; - - const spinner = loading ? : undefined; - const classes = classNames({ - mx_MessageActionBar_iconButton: true, - mx_MessageActionBar_downloadButton: true, - mx_MessageActionBar_downloadSpinnerButton: !!spinner, - }); - - return ( - - - {spinner} - - ); -} diff --git a/apps/web/src/components/views/messages/EditHistoryMessage.tsx b/apps/web/src/components/views/messages/EditHistoryMessage.tsx index e6af74f2b1..9d533a792a 100644 --- a/apps/web/src/components/views/messages/EditHistoryMessage.tsx +++ b/apps/web/src/components/views/messages/EditHistoryMessage.tsx @@ -6,17 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX, createRef } from "react"; +import React, { createRef } from "react"; import { type EventStatus, type IContent, type MatrixEvent, MatrixEventEvent, MsgType } from "matrix-js-sdk/src/matrix"; import classNames from "classnames"; -import { EventContentBodyView } from "@element-hq/web-shared-components"; +import { ActionBarView, EventContentBodyView } from "@element-hq/web-shared-components"; +import { EditHistoryActionBarViewModel } from "../../../viewmodels/message-body/EditHistoryActionBarViewModel"; import { EventContentBodyViewModel } from "../../../viewmodels/message-body/EventContentBodyViewModel"; import { editBodyDiffToHtml } from "../../../utils/MessageDiffUtils"; import { formatTime } from "../../../DateUtils"; -import { _t } from "../../../languageHandler"; import Modal from "../../../Modal"; -import AccessibleButton from "../elements/AccessibleButton"; import ConfirmAndWaitRedactDialog from "../dialogs/ConfirmAndWaitRedactDialog"; import ViewSource from "../../structures/ViewSource"; import SettingsStore from "../../../settings/SettingsStore"; @@ -47,6 +46,7 @@ export default class EditHistoryMessage extends React.PureComponent(); private EventContentBodyViewModel: EventContentBodyViewModel; + private editHistoryActionBarViewModel: EditHistoryActionBarViewModel; public constructor(props: IProps, context: React.ContextType) { super(props, context); @@ -72,6 +72,13 @@ export default class EditHistoryMessage extends React.PureComponent { @@ -116,34 +130,20 @@ export default class EditHistoryMessage extends React.PureComponent{_t("action|remove")}; - } + this.editHistoryActionBarViewModel.setProps({ + canRemove: !this.props.mxEvent.isRedacted() && !this.props.isBaseEvent && this.state.canRedact, + showViewSource: SettingsStore.getValue("developerMode"), + onRemoveClick: this.onRedactClick, + onViewSourceClick: this.onViewSourceClick, + }); - let viewSourceButton: JSX.Element | undefined; - if (SettingsStore.getValue("developerMode")) { - viewSourceButton = ( - {_t("action|view_source")} - ); - } - - if (!redactButton && !viewSourceButton) { - // Hide the empty MessageActionBar - return null; - } else { - // disabled remove button when not allowed - return ( -
- {redactButton} - {viewSourceButton} -
- ); - } + return ( + + ); } public render(): React.ReactNode { diff --git a/apps/web/src/components/views/messages/HideActionButton.tsx b/apps/web/src/components/views/messages/HideActionButton.tsx deleted file mode 100644 index ba0c8568f1..0000000000 --- a/apps/web/src/components/views/messages/HideActionButton.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/* -Copyright 2024, 2025 New Vector Ltd. -Copyright 2021 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; -import React from "react"; -import { VisibilityOffIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; - -import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex"; -import { _t } from "../../../languageHandler"; -import { useMediaVisible } from "../../../hooks/useMediaVisible"; - -interface IProps { - /** - * Matrix event that this action applies to. - */ - mxEvent: MatrixEvent; -} - -/** - * Quick action button for marking a media event as hidden. - */ -export const HideActionButton: React.FC = ({ mxEvent }) => { - const [mediaIsVisible, setVisible] = useMediaVisible(mxEvent); - - if (!mediaIsVisible) { - return; - } - - return ( - setVisible(false)} - placement="left" - > - - - ); -}; diff --git a/apps/web/src/components/views/messages/MBodyFactory.tsx b/apps/web/src/components/views/messages/MBodyFactory.tsx index 3e79bc6e66..deb10cd822 100644 --- a/apps/web/src/components/views/messages/MBodyFactory.tsx +++ b/apps/web/src/components/views/messages/MBodyFactory.tsx @@ -11,15 +11,18 @@ import { DecryptionFailureBodyView, FileBodyView, RedactedBodyView, + VideoBodyView, useCreateAutoDisposedViewModel, } from "@element-hq/web-shared-components"; import { type IBodyProps } from "./IBodyProps"; -import RoomContext from "../../../contexts/RoomContext"; +import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext"; +import { useMediaVisible } from "../../../hooks/useMediaVisible"; import { DecryptionFailureBodyViewModel } from "../../../viewmodels/room/timeline/event-tile/body/DecryptionFailureBodyViewModel"; import { FileBodyViewModel } from "../../../viewmodels/message-body/FileBodyViewModel"; import { RedactedBodyViewModel } from "../../../viewmodels/message-body/RedactedBodyViewModel"; +import { VideoBodyViewModel } from "../../../viewmodels/message-body/VideoBodyViewModel"; type MBodyComponent = React.ComponentType; @@ -59,6 +62,78 @@ export function FileBodyFactory({ return ; } +export function VideoBodyFactory({ + mxEvent, + mediaEventHelper, + forExport, + inhibitInteraction, +}: Readonly>): JSX.Element { + const { timelineRenderingType } = useContext(RoomContext); + const [mediaVisible, setMediaVisible] = useMediaVisible(mxEvent); + const videoRef = useRef(null); + + const vm = useCreateAutoDisposedViewModel( + () => + new VideoBodyViewModel({ + mxEvent, + mediaEventHelper, + forExport, + inhibitInteraction, + mediaVisible, + onPreviewClick: (): void => setMediaVisible(true), + videoRef, + }), + ); + + useEffect(() => { + vm.loadInitialMediaIfVisible(); + }, [vm]); + + useEffect(() => { + vm.setEvent(mxEvent, mediaEventHelper); + }, [mxEvent, mediaEventHelper, vm]); + + useEffect(() => { + vm.setForExport(forExport); + }, [forExport, vm]); + + useEffect(() => { + vm.setInhibitInteraction(inhibitInteraction); + }, [inhibitInteraction, vm]); + + useEffect(() => { + vm.setMediaVisible(mediaVisible); + }, [mediaVisible, vm]); + + useEffect(() => { + vm.setOnPreviewClick((): void => setMediaVisible(true)); + }, [setMediaVisible, vm]); + + const showFileBody = + !forExport && + timelineRenderingType !== TimelineRenderingType.Room && + timelineRenderingType !== TimelineRenderingType.Pinned && + timelineRenderingType !== TimelineRenderingType.Search; + + return ( + + {showFileBody ? ( + + ) : null} + + ); +} + export function RedactedBodyFactory({ mxEvent, ref }: Pick): JSX.Element { const vm = useCreateAutoDisposedViewModel(() => new RedactedBodyViewModel({ mxEvent })); @@ -87,9 +162,11 @@ export function DecryptionFailureBodyFactory({ mxEvent, ref }: Pick; } -// Message body factory registry. -// Start small: only m.file currently routes to the new FileBodyView path. -const MESSAGE_BODY_TYPES = new Map([[MsgType.File, FileBodyFactory]]); +// Message body factory registry for bodies that already route through view-model-backed wrappers. +const MESSAGE_BODY_TYPES = new Map([ + [MsgType.File, FileBodyFactory], + [MsgType.Video, VideoBodyFactory], +]); // Render a body using the picked factory. // Falls back to the provided factory when msgtype has no specific handler. diff --git a/apps/web/src/components/views/messages/MVideoBody.tsx b/apps/web/src/components/views/messages/MVideoBody.tsx deleted file mode 100644 index 848dc7a32e..0000000000 --- a/apps/web/src/components/views/messages/MVideoBody.tsx +++ /dev/null @@ -1,353 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2015-2021 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import React, { type ReactNode } from "react"; -import { decode } from "blurhash"; -import { type MediaEventContent } from "matrix-js-sdk/src/types"; -import { logger } from "matrix-js-sdk/src/logger"; - -import { _t } from "../../../languageHandler"; -import SettingsStore from "../../../settings/SettingsStore"; -import InlineSpinner from "../elements/InlineSpinner"; -import { mediaFromContent } from "../../../customisations/Media"; -import { BLURHASH_FIELD } from "../../../utils/image-media"; -import { type IBodyProps } from "./IBodyProps"; -import { type ImageSize, suggestedSize as suggestedVideoSize } from "../../../settings/enums/ImageSize"; -import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; -import MediaProcessingError from "./shared/MediaProcessingError"; -import { HiddenMediaPlaceholder } from "./HiddenMediaPlaceholder"; -import { useMediaVisible } from "../../../hooks/useMediaVisible"; -import { FileBodyFactory, renderMBody } from "./MBodyFactory"; - -interface IState { - decryptedUrl: string | null; - decryptedThumbnailUrl: string | null; - decryptedBlob: Blob | null; - error?: any; - fetchingData: boolean; - posterLoading: boolean; - blurhashUrl: string | null; -} - -interface IProps extends IBodyProps { - /** - * Should the media be behind a preview. - */ - mediaVisible: boolean; - /** - * Set the visibility of the media event. - * @param visible Should the event be visible. - */ - setMediaVisible: (visible: boolean) => void; -} - -class MVideoBodyInner extends React.PureComponent { - public static contextType = RoomContext; - declare public context: React.ContextType; - - private videoRef = React.createRef(); - private sizeWatcher?: string; - - public state = { - fetchingData: false, - decryptedUrl: null, - decryptedThumbnailUrl: null, - decryptedBlob: null, - error: null, - posterLoading: false, - blurhashUrl: null, - }; - - private onClick = (): void => { - this.props.setMediaVisible(true); - }; - - private getContentUrl(): string | undefined { - const content = this.props.mxEvent.getContent(); - // During export, the content url will point to the MSC, which will later point to a local url - if (this.props.forExport) return content.file?.url ?? content.url; - const media = mediaFromContent(content); - if (media.isEncrypted) { - return this.state.decryptedUrl ?? undefined; - } else { - return media.srcHttp ?? undefined; - } - } - - private hasContentUrl(): boolean { - const url = this.getContentUrl(); - return !!url && !url.startsWith("data:"); - } - - private getThumbUrl(): string | null { - // there's no need of thumbnail when the content is local - if (this.props.forExport) return null; - - const content = this.props.mxEvent.getContent(); - const media = mediaFromContent(content); - - if (media.isEncrypted && this.state.decryptedThumbnailUrl) { - return this.state.decryptedThumbnailUrl; - } else if (this.state.posterLoading) { - return this.state.blurhashUrl; - } else if (media.hasThumbnail) { - return media.thumbnailHttp; - } else { - return null; - } - } - - private loadBlurhash(): void { - const info = this.props.mxEvent.getContent()?.info; - if (!info[BLURHASH_FIELD]) return; - - const canvas = document.createElement("canvas"); - - const { w: width, h: height } = suggestedVideoSize(SettingsStore.getValue("Images.size") as ImageSize, { - w: info.w, - h: info.h, - }); - - canvas.width = width; - canvas.height = height; - - const pixels = decode(info[BLURHASH_FIELD], width, height); - const ctx = canvas.getContext("2d")!; - const imgData = ctx.createImageData(width, height); - imgData.data.set(pixels); - ctx.putImageData(imgData, 0, 0); - - this.setState({ - blurhashUrl: canvas.toDataURL(), - posterLoading: true, - }); - - const content = this.props.mxEvent.getContent(); - const media = mediaFromContent(content); - if (media.hasThumbnail) { - const image = new Image(); - image.onload = () => { - this.setState({ posterLoading: false }); - }; - image.src = media.thumbnailHttp!; - } - } - - private async downloadVideo(): Promise { - try { - this.loadBlurhash(); - } catch (e) { - logger.error("Failed to load blurhash", e); - } - - if (this.props.mediaEventHelper?.media.isEncrypted && this.state.decryptedUrl === null) { - try { - const autoplay = SettingsStore.getValue("autoplayVideo") as boolean; - const thumbnailUrl = await this.props.mediaEventHelper.thumbnailUrl.value; - if (autoplay) { - logger.log("Preloading video"); - this.setState({ - decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value, - decryptedThumbnailUrl: thumbnailUrl, - decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value, - }); - } else { - logger.log("NOT preloading video"); - const content = this.props.mxEvent.getContent(); - - let mimetype = content?.info?.mimetype; - - // clobber quicktime muxed files to be considered MP4 so browsers - // are willing to play them - if (mimetype == "video/quicktime") { - mimetype = "video/mp4"; - } - - this.setState({ - // For Chrome and Electron, we need to set some non-empty `src` to - // enable the play button. Firefox does not seem to care either - // way, so it's fine to do for all browsers. - decryptedUrl: `data:${mimetype},`, - decryptedThumbnailUrl: thumbnailUrl || `data:${mimetype},`, - decryptedBlob: null, - }); - } - } catch (err) { - logger.warn("Unable to decrypt attachment: ", err); - // Set a placeholder image when we can't decrypt the image. - this.setState({ - error: err, - }); - } - } - } - - public async componentDidMount(): Promise { - this.sizeWatcher = SettingsStore.watchSetting("Images.size", null, () => { - this.forceUpdate(); // we don't really have a reliable thing to update, so just update the whole thing - }); - - // Do not attempt to load the media if we do not want to show previews here. - if (this.props.mediaVisible) { - await this.downloadVideo(); - } - } - - public async componentDidUpdate(prevProps: Readonly): Promise { - if (!prevProps.mediaVisible && this.props.mediaVisible) { - await this.downloadVideo(); - } - } - - public componentWillUnmount(): void { - SettingsStore.unwatchSetting(this.sizeWatcher); - } - - private videoOnPlay = async (): Promise => { - if (this.hasContentUrl() || this.state.fetchingData || this.state.error) { - // We have the file, we are fetching the file, or there is an error. - return; - } - this.setState({ - // To stop subsequent download attempts - fetchingData: true, - }); - if (!this.props.mediaEventHelper!.media.isEncrypted) { - this.setState({ - error: "No file given in content", - }); - return; - } - this.setState( - { - decryptedUrl: await this.props.mediaEventHelper!.sourceUrl.value, - decryptedBlob: await this.props.mediaEventHelper!.sourceBlob.value, - fetchingData: false, - }, - () => { - if (!this.videoRef.current) return; - this.videoRef.current.play(); - }, - ); - }; - - protected get showFileBody(): boolean { - return ( - this.context.timelineRenderingType !== TimelineRenderingType.Room && - this.context.timelineRenderingType !== TimelineRenderingType.Pinned && - this.context.timelineRenderingType !== TimelineRenderingType.Search - ); - } - - private getFileBody = (): ReactNode => { - if (this.props.forExport) return null; - return this.showFileBody && renderMBody({ ...this.props, showFileInfo: false }, FileBodyFactory); - }; - - public render(): React.ReactNode { - const content = this.props.mxEvent.getContent(); - const autoplay = !this.props.inhibitInteraction && SettingsStore.getValue("autoplayVideo"); - - let aspectRatio; - if (content.info?.w && content.info?.h) { - aspectRatio = `${content.info.w}/${content.info.h}`; - } - const { w: maxWidth, h: maxHeight } = suggestedVideoSize(SettingsStore.getValue("Images.size") as ImageSize, { - w: content.info?.w, - h: content.info?.h, - }); - - // HACK: This div fills out space while the video loads, to prevent scroll jumps - const spaceFiller =
; - - if (this.state.error !== null) { - return ( - - {_t("timeline|m.video|error_decrypting")} - - ); - } - - // Users may not even want to show a poster, so instead show a preview button. - if (!this.props.mediaVisible) { - return ( - -
- - {_t("timeline|m.video|show_video")} - -
-
- ); - } - - // Important: If we aren't autoplaying and we haven't decrypted it yet, show a video with a poster. - if (!this.props.forExport && content.file !== undefined && this.state.decryptedUrl === null && autoplay) { - // Need to decrypt the attachment - // The attachment is decrypted in componentDidMount. - // For now show a spinner. - return ( - -
- -
- {spaceFiller} -
- ); - } - - const contentUrl = this.getContentUrl(); - const thumbUrl = this.getThumbUrl(); - let poster: string | undefined; - let preload = "metadata"; - if (content.info && thumbUrl) { - poster = thumbUrl; - preload = "none"; - } - - const fileBody = this.getFileBody(); - return ( - -
-
- {fileBody} -
- ); - } -} - -// Wrap MVideoBody component so we can use a hook here. -const MVideoBody: React.FC = (props) => { - const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent); - return ; -}; - -export default MVideoBody; diff --git a/apps/web/src/components/views/messages/MessageActionBar.tsx b/apps/web/src/components/views/messages/MessageActionBar.tsx deleted file mode 100644 index 5462ca4213..0000000000 --- a/apps/web/src/components/views/messages/MessageActionBar.tsx +++ /dev/null @@ -1,601 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2019-2023 The Matrix.org Foundation C.I.C. -Copyright 2019 New Vector Ltd -Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> - -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, type ReactElement, useCallback, useContext, useEffect } from "react"; -import { - EventStatus, - type MatrixEvent, - MatrixEventEvent, - MsgType, - RelationType, - M_BEACON_INFO, - EventTimeline, - RoomStateEvent, - EventType, - type Relations, -} from "matrix-js-sdk/src/matrix"; -import classNames from "classnames"; -import { - PinIcon, - UnpinIcon, - OverflowHorizontalIcon, - ReplyIcon, - DeleteIcon, - RestartIcon, - ThreadsIcon, - EditIcon, - ReactionAddIcon, - ExpandIcon, - CollapseIcon, -} from "@vector-im/compound-design-tokens/assets/web/icons"; - -import { _t } from "../../../languageHandler"; -import defaultDispatcher from "../../../dispatcher/dispatcher"; -import ContextMenu, { aboveLeftOf, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu"; -import { isContentActionable, canEditContent, editEvent, canCancel } from "../../../utils/EventUtils"; -import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; -import Toolbar from "../../../accessibility/Toolbar"; -import { RovingAccessibleButton, useRovingTabIndex } from "../../../accessibility/RovingTabIndex"; -import MessageContextMenu from "../context_menus/MessageContextMenu"; -import Resend from "../../../Resend"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import { MediaEventHelper } from "../../../utils/MediaEventHelper"; -import DownloadActionButton from "./DownloadActionButton"; -import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; -import type ReplyChain from "../elements/ReplyChain"; -import ReactionPicker from "../emojipicker/ReactionPicker"; -import { CardContext } from "../right_panel/context"; -import { shouldDisplayReply } from "../../../utils/Reply"; -import { Key } from "../../../Keyboard"; -import { ALTERNATE_KEY_NAME } from "../../../accessibility/KeyboardShortcuts"; -import { Action } from "../../../dispatcher/actions"; -import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; -import { type GetRelationsForEvent, type IEventTileType } from "../rooms/EventTile"; -import { type ButtonEvent } from "../elements/AccessibleButton"; -import PinningUtils from "../../../utils/PinningUtils"; -import PosthogTrackers from "../../../PosthogTrackers.ts"; -import { HideActionButton } from "./HideActionButton.tsx"; - -interface IOptionsButtonProps { - mxEvent: MatrixEvent; - getTile: () => IEventTileType | null; - getReplyChain: () => ReplyChain | null; - permalinkCreator?: RoomPermalinkCreator; - onFocusChange: (menuDisplayed: boolean) => void; - getRelationsForEvent?: GetRelationsForEvent; -} - -const OptionsButton: React.FC = ({ - mxEvent, - getTile, - getReplyChain, - permalinkCreator, - onFocusChange, - getRelationsForEvent, -}) => { - const [onFocus, isActive, buttonRefCallback, buttonRef] = useRovingTabIndex(); - const [menuDisplayed, , openMenu, closeMenu] = useContextMenu(buttonRef); - useEffect(() => { - onFocusChange(menuDisplayed); - }, [onFocusChange, menuDisplayed]); - - const onOptionsClick = useCallback( - (e: ButtonEvent): void => { - // Don't open the regular browser or our context menu on right-click - e.preventDefault(); - e.stopPropagation(); - openMenu(); - // when the context menu is opened directly, e.g. via mouse click, the onFocus handler which tracks - // the element that is currently focused is skipped. So we want to call onFocus manually to keep the - // position in the page even when someone is clicking around. - onFocus(); - }, - [openMenu, onFocus], - ); - - let contextMenu: ReactElement | undefined; - if (menuDisplayed && buttonRef.current) { - const tile = getTile?.(); - const replyChain = getReplyChain(); - - const buttonRect = buttonRef.current.getBoundingClientRect(); - contextMenu = ( - - ); - } - - return ( - - - - - {contextMenu} - - ); -}; - -interface IReactButtonProps { - mxEvent: MatrixEvent; - reactions?: Relations | null | undefined; - onFocusChange: (menuDisplayed: boolean) => void; -} - -const ReactButton: React.FC = ({ mxEvent, reactions, onFocusChange }) => { - const [onFocus, isActive, buttonRefCallback, buttonRef] = useRovingTabIndex(); - const [menuDisplayed, , openMenu, closeMenu] = useContextMenu(buttonRef); - useEffect(() => { - onFocusChange(menuDisplayed); - }, [onFocusChange, menuDisplayed]); - - let contextMenu: JSX.Element | undefined; - if (menuDisplayed && buttonRef.current) { - const buttonRect = buttonRef.current.getBoundingClientRect(); - contextMenu = ( - - - - ); - } - - const onClick = useCallback( - (e: ButtonEvent) => { - // Don't open the regular browser or our context menu on right-click - e.preventDefault(); - e.stopPropagation(); - - openMenu(); - // when the context menu is opened directly, e.g. via mouse click, the onFocus handler which tracks - // the element that is currently focused is skipped. So we want to call onFocus manually to keep the - // position in the page even when someone is clicking around. - onFocus(); - }, - [openMenu, onFocus], - ); - - return ( - - - - - - {contextMenu} - - ); -}; - -interface IReplyInThreadButton { - mxEvent: MatrixEvent; -} - -const ReplyInThreadButton: React.FC = ({ mxEvent }) => { - const context = useContext(CardContext); - - const relationType = mxEvent?.getRelation()?.rel_type; - const hasARelation = !!relationType && relationType !== RelationType.Thread; - - const onClick = (e: ButtonEvent): void => { - // Don't open the regular browser or our context menu on right-click - e.preventDefault(); - e.stopPropagation(); - - const thread = mxEvent.getThread(); - if (thread?.rootEvent && !mxEvent.isThreadRoot) { - defaultDispatcher.dispatch({ - action: Action.ShowThread, - rootEvent: thread.rootEvent, - initialEvent: mxEvent, - scroll_into_view: true, - highlighted: true, - push: context.isCard, - }); - } else { - defaultDispatcher.dispatch({ - action: Action.ShowThread, - rootEvent: mxEvent, - push: context.isCard, - }); - } - }; - - const title = !hasARelation ? _t("action|reply_in_thread") : _t("threads|error_start_thread_existing_relation"); - - return ( - - - - ); -}; - -interface IMessageActionBarProps { - mxEvent: MatrixEvent; - reactions?: Relations | null | undefined; - getTile: () => IEventTileType | null; - getReplyChain: () => ReplyChain | null; - permalinkCreator?: RoomPermalinkCreator; - onFocusChange?: (menuDisplayed: boolean) => void; - toggleThreadExpanded: () => void; - isQuoteExpanded?: boolean; - getRelationsForEvent?: GetRelationsForEvent; -} - -export default class MessageActionBar extends React.PureComponent { - public static contextType = RoomContext; - declare public context: React.ContextType; - - public componentDidMount(): void { - if (this.props.mxEvent.status && this.props.mxEvent.status !== EventStatus.SENT) { - this.props.mxEvent.on(MatrixEventEvent.Status, this.onSent); - } - - const client = MatrixClientPeg.safeGet(); - client.decryptEventIfNeeded(this.props.mxEvent); - - if (this.props.mxEvent.isBeingDecrypted()) { - this.props.mxEvent.once(MatrixEventEvent.Decrypted, this.onDecrypted); - } - this.props.mxEvent.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); - this.context.room - ?.getLiveTimeline() - .getState(EventTimeline.FORWARDS) - ?.on(RoomStateEvent.Events, this.onRoomEvent); - } - - public componentWillUnmount(): void { - this.props.mxEvent.off(MatrixEventEvent.Status, this.onSent); - this.props.mxEvent.off(MatrixEventEvent.Decrypted, this.onDecrypted); - this.props.mxEvent.off(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); - this.context.room - ?.getLiveTimeline() - .getState(EventTimeline.FORWARDS) - ?.off(RoomStateEvent.Events, this.onRoomEvent); - } - - private onDecrypted = (): void => { - // When an event decrypts, it is likely to change the set of available - // actions, so we force an update to check again. - this.forceUpdate(); - }; - - private onBeforeRedaction = (): void => { - // When an event is redacted, we can't edit it so update the available actions. - this.forceUpdate(); - }; - - private onRoomEvent = (event?: MatrixEvent): void => { - // If the event is pinned or unpinned, rerender the component. - if (!event || event.getType() !== EventType.RoomPinnedEvents) return; - this.forceUpdate(); - }; - - private onSent = (): void => { - // When an event is sent and echoed the possible actions change. - this.forceUpdate(); - }; - - private onFocusChange = (focused: boolean): void => { - this.props.onFocusChange?.(focused); - }; - - private onReplyClick = (e: ButtonEvent): void => { - // Don't open the regular browser or our context menu on right-click - e.preventDefault(); - e.stopPropagation(); - - defaultDispatcher.dispatch({ - action: "reply_to_event", - event: this.props.mxEvent, - context: this.context.timelineRenderingType, - }); - }; - - private onEditClick = (e: ButtonEvent): void => { - // Don't open the regular browser or our context menu on right-click - e.preventDefault(); - e.stopPropagation(); - - editEvent( - MatrixClientPeg.safeGet(), - this.props.mxEvent, - this.context.timelineRenderingType, - this.props.getRelationsForEvent, - ); - }; - - private readonly forbiddenThreadHeadMsgType = [MsgType.KeyVerificationRequest]; - - private get showReplyInThreadAction(): boolean { - const inNotThreadTimeline = this.context.timelineRenderingType !== TimelineRenderingType.Thread; - - const isAllowedMessageType = - !this.forbiddenThreadHeadMsgType.includes(this.props.mxEvent.getContent().msgtype as MsgType) && - /** forbid threads from live location shares - * until cross-platform support - * (PSF-1041) - */ - !M_BEACON_INFO.matches(this.props.mxEvent.getType()); - - return inNotThreadTimeline && isAllowedMessageType; - } - - /** - * Runs a given fn on the set of possible events to test. The first event - * that passes the checkFn will have fn executed on it. Both functions take - * a MatrixEvent object. If no particular conditions are needed, checkFn can - * be null/undefined. If no functions pass the checkFn, no action will be - * taken. - * @param {Function} fn The execution function. - * @param {Function} checkFn The test function. - */ - private runActionOnFailedEv(fn: (ev: MatrixEvent) => void, checkFn?: (ev: MatrixEvent) => boolean): void { - if (!checkFn) checkFn = () => true; - - const mxEvent = this.props.mxEvent; - const editEvent = mxEvent.replacingEvent(); - const redactEvent = mxEvent.localRedactionEvent(); - const tryOrder = [redactEvent, editEvent, mxEvent]; - for (const ev of tryOrder) { - if (ev && checkFn(ev)) { - fn(ev); - break; - } - } - } - - private onResendClick = (ev: ButtonEvent): void => { - // Don't open the regular browser or our context menu on right-click - ev.preventDefault(); - ev.stopPropagation(); - - this.runActionOnFailedEv((tarEv) => Resend.resend(MatrixClientPeg.safeGet(), tarEv)); - }; - - private onCancelClick = (ev: ButtonEvent): void => { - this.runActionOnFailedEv( - (tarEv) => Resend.removeFromQueue(MatrixClientPeg.safeGet(), tarEv), - (testEv) => canCancel(testEv.status), - ); - }; - - /** - * Pin or unpin the event. - */ - private onPinClick = async (event: ButtonEvent, isPinned: boolean): Promise => { - // Don't open the regular browser or our context menu on right-click - event.preventDefault(); - event.stopPropagation(); - - await PinningUtils.pinOrUnpinEvent(MatrixClientPeg.safeGet(), this.props.mxEvent); - PosthogTrackers.trackPinUnpinMessage(isPinned ? "Pin" : "Unpin", "Timeline"); - }; - - public render(): React.ReactNode { - const toolbarOpts: JSX.Element[] = []; - if (canEditContent(MatrixClientPeg.safeGet(), this.props.mxEvent)) { - toolbarOpts.push( - - - , - ); - } - - if ( - PinningUtils.canPin(MatrixClientPeg.safeGet(), this.props.mxEvent) || - PinningUtils.canUnpin(MatrixClientPeg.safeGet(), this.props.mxEvent) - ) { - const isPinned = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent); - toolbarOpts.push( - this.onPinClick(e, isPinned)} - onContextMenu={(e: ButtonEvent) => this.onPinClick(e, isPinned)} - key="pin" - placement="top" - > - {isPinned ? : } - , - ); - } - - const cancelSendingButton = ( - - - - ); - - const threadTooltipButton = ; - - // We show a different toolbar for failed events, so detect that first. - const mxEvent = this.props.mxEvent; - const editStatus = mxEvent.replacingEvent()?.status; - const redactStatus = mxEvent.localRedactionEvent()?.status; - const allowCancel = canCancel(mxEvent.status) || canCancel(editStatus) || canCancel(redactStatus); - const isFailed = [mxEvent.status, editStatus, redactStatus].includes(EventStatus.NOT_SENT); - if (allowCancel && isFailed) { - // The resend button needs to appear ahead of the edit button, so insert to the - // start of the opts - toolbarOpts.splice( - 0, - 0, - - - , - ); - - // The delete button should appear last, so we can just drop it at the end - toolbarOpts.push(cancelSendingButton); - } else { - if (isContentActionable(this.props.mxEvent)) { - // Like the resend button, the react and reply buttons need to appear before the edit. - // The only catch is we do the reply button first so that we can make sure the react - // button is the very first button without having to do length checks for `splice()`. - - if (this.context.canSendMessages) { - if (this.showReplyInThreadAction) { - toolbarOpts.splice(0, 0, threadTooltipButton); - } - toolbarOpts.splice( - 0, - 0, - - - , - ); - } - // We hide the react button in search results as we don't show reactions in results - if (this.context.canReact && !this.context.search) { - toolbarOpts.splice( - 0, - 0, - , - ); - } - - // XXX: Assuming that the underlying tile will be a media event if it is eligible media. - if (MediaEventHelper.isEligible(this.props.mxEvent)) { - toolbarOpts.splice( - 0, - 0, - this.props.getTile()?.getMediaHelper?.()} - key="download" - />, - ); - } - if (MediaEventHelper.canHide(this.props.mxEvent)) { - toolbarOpts.splice(0, 0, ); - } - } else if ( - // Show thread icon even for deleted messages, but only within main timeline - this.context.timelineRenderingType === TimelineRenderingType.Room && - this.props.mxEvent.getThread() - ) { - toolbarOpts.unshift(threadTooltipButton); - } - - if (allowCancel) { - toolbarOpts.push(cancelSendingButton); - } - - if (this.props.isQuoteExpanded !== undefined && shouldDisplayReply(this.props.mxEvent)) { - const expandClassName = classNames({ - mx_MessageActionBar_iconButton: true, - mx_MessageActionBar_expandCollapseMessageButton: true, - }); - - toolbarOpts.push( - - {this.props.isQuoteExpanded ? : } - , - ); - } - - // The menu button should be last, so dump it there. - toolbarOpts.push( - , - ); - } - - // aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive. - return ( - - {toolbarOpts} - - ); - } -} diff --git a/apps/web/src/components/views/messages/MessageEvent.tsx b/apps/web/src/components/views/messages/MessageEvent.tsx index d8e0b656bb..549f38226c 100644 --- a/apps/web/src/components/views/messages/MessageEvent.tsx +++ b/apps/web/src/components/views/messages/MessageEvent.tsx @@ -28,14 +28,19 @@ import { type IBodyProps } from "./IBodyProps"; import TextualBody from "./TextualBody"; import MImageBody from "./MImageBody"; import MVoiceOrAudioBody from "./MVoiceOrAudioBody"; -import MVideoBody from "./MVideoBody"; import MStickerBody from "./MStickerBody"; import MPollBody from "./MPollBody"; import MLocationBody from "./MLocationBody"; import MjolnirBody from "./MjolnirBody"; import MBeaconBody from "./MBeaconBody"; import { type GetRelationsForEvent, type IEventTileOps } from "../rooms/EventTile"; -import { DecryptionFailureBodyFactory, FileBodyFactory, RedactedBodyFactory, renderMBody } from "./MBodyFactory"; +import { + DecryptionFailureBodyFactory, + FileBodyFactory, + RedactedBodyFactory, + VideoBodyFactory, + renderMBody, +} from "./MBodyFactory"; // onMessageAllowed is handled internally interface IProps extends Omit { @@ -65,7 +70,7 @@ const baseBodyTypes = new Map>([ [MsgType.Image, MImageBody], [MsgType.File, (props: IBodyProps) => renderMBody(props, FileBodyFactory)!], [MsgType.Audio, MVoiceOrAudioBody], - [MsgType.Video, MVideoBody], + [MsgType.Video, VideoBodyFactory], ]); const baseEvTypes = new Map>([ [EventType.Sticker, MStickerBody], @@ -260,7 +265,8 @@ export default class MessageEvent extends React.Component implements IMe } if ( - ((BodyType === MImageBody || BodyType == MVideoBody) && !this.validateImageOrVideoMimetype(content)) || + ((BodyType === MImageBody || BodyType === VideoBodyFactory) && + !this.validateImageOrVideoMimetype(content)) || (BodyType === MStickerBody && !this.validateStickerMimetype(content)) ) { BodyType = this.bodyTypes.get(MsgType.File)!; diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index aa0f9ac0e4..922f0171cf 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -16,6 +16,7 @@ import React, { useState, type JSX, type Ref, + type FocusEvent, type MouseEvent, type ReactNode, } from "react"; @@ -50,6 +51,7 @@ import { uniqueId, uniqBy } from "lodash"; import { CircleIcon, CheckCircleIcon, ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { useCreateAutoDisposedViewModel, + ActionBarView, MessageTimestampView, PinnedMessageBadge, ReactionsRowButtonView, @@ -77,13 +79,11 @@ import PlatformPeg from "../../../PlatformPeg"; import MemberAvatar from "../avatars/MemberAvatar"; import SenderProfile from "../messages/SenderProfile"; import { type IReadReceiptPosition } from "./ReadReceiptMarker"; -import MessageActionBar from "../messages/MessageActionBar"; 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 { type ButtonEvent } from "../elements/AccessibleButton"; import { copyPlaintext } from "../../../utils/strings"; import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; @@ -96,7 +96,6 @@ import { ReadReceiptGroup } from "./ReadReceiptGroup"; import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom"; import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge"; -import { EventTileThreadToolbar } from "./EventTile/EventTileThreadToolbar"; import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper"; import { Icon as LateIcon } from "../../../../res/img/sensor.svg"; import PinningUtils from "../../../utils/PinningUtils"; @@ -105,6 +104,7 @@ import { ElementCallEventType } from "../../../call-types"; import { E2eMessageSharedIcon } from "./EventTile/E2eMessageSharedIcon.tsx"; import { E2ePadlock, E2ePadlockIcon } from "./EventTile/E2ePadlock.tsx"; import SettingsStore from "../../../settings/SettingsStore"; +import { CardContext } from "../right_panel/context"; import { MessageTimestampViewModel, type MessageTimestampViewModelProps, @@ -114,6 +114,8 @@ import { MAX_ITEMS_WHEN_LIMITED, ReactionsRowViewModel, } from "../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowViewModel"; +import { EventTileActionBarViewModel } from "../../../viewmodels/room/EventTileActionBarViewModel"; +import { ThreadListActionBarViewModel } from "../../../viewmodels/room/ThreadListActionBarViewModel"; import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory"; @@ -268,6 +270,7 @@ export interface EventTileProps { interface IState { // Whether the action bar is focused. actionBarFocused: boolean; + showActionBarFromFocus: boolean; /** * E2EE shield we should show for decryption problems. @@ -342,6 +345,7 @@ export class UnwrappedEventTile extends React.Component this.state = { // Whether the action bar is focused. actionBarFocused: false, + showActionBarFromFocus: false, shieldColour: EventShieldColour.NONE, shieldReason: null, @@ -453,7 +457,7 @@ export class UnwrappedEventTile extends React.Component this.verifyEvent(); } - private updateThread = (thread: Thread): void => { + private readonly updateThread = (thread: Thread): void => { this.setState({ thread }); }; @@ -498,7 +502,7 @@ export class UnwrappedEventTile extends React.Component if (this.props.resizeObserver && this.ref.current) this.props.resizeObserver.observe(this.ref.current); } - private onNewThread = (thread: Thread): void => { + private readonly onNewThread = (thread: Thread): void => { if (thread.id === this.props.mxEvent.getId()) { this.updateThread(thread); const room = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId()); @@ -561,9 +565,7 @@ export class UnwrappedEventTile extends React.Component } } - private viewInRoom = (evt: ButtonEvent): void => { - evt.preventDefault(); - evt.stopPropagation(); + private readonly onViewInRoomClick = (_anchor: HTMLElement | null): void => { dis.dispatch({ action: Action.ViewRoom, event_id: this.props.mxEvent.getId(), @@ -573,16 +575,14 @@ export class UnwrappedEventTile extends React.Component }); }; - private copyLinkToThread = async (evt: ButtonEvent): Promise => { - evt.preventDefault(); - evt.stopPropagation(); + private readonly onCopyLinkToThreadClick = async (_anchor: HTMLElement | null): Promise => { const { permalinkCreator, mxEvent } = this.props; if (!permalinkCreator) return; const matrixToUrl = permalinkCreator.forEvent(mxEvent.getId()!); await copyPlaintext(matrixToUrl); }; - private onRoomReceipt = (ev: MatrixEvent, room: Room): void => { + private readonly onRoomReceipt = (ev: MatrixEvent, room: Room): void => { // ignore events for other rooms const tileRoom = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId()); if (room !== tileRoom) return; @@ -604,20 +604,20 @@ export class UnwrappedEventTile extends React.Component /** called when the event is decrypted after we show it. */ - private onDecrypted = (): void => { + private readonly onDecrypted = (): void => { // we need to re-verify the sending device. this.verifyEvent(); this.forceUpdate(); }; - private onUserVerificationChanged = (userId: string, _trustStatus: UserVerificationStatus): void => { + private readonly onUserVerificationChanged = (userId: string, _trustStatus: UserVerificationStatus): void => { if (userId === this.props.mxEvent.getSender()) { this.verifyEvent(); } }; /** called when the event is edited after we show it. */ - private onReplaced = (): void => { + private readonly onReplaced = (): void => { // re-verify the event if it is replaced (the edit may not be verified) this.verifyEvent(); }; @@ -732,7 +732,7 @@ export class UnwrappedEventTile extends React.Component return !!(actions?.tweaks.highlight || previousActions?.tweaks.highlight); } - private onSenderProfileClick = (): void => { + private readonly onSenderProfileClick = (): void => { dis.dispatch({ action: Action.ComposerInsert, userId: this.props.mxEvent.getSender()!, @@ -740,7 +740,7 @@ export class UnwrappedEventTile extends React.Component }); }; - private onPermalinkClicked = (e: MouseEvent): void => { + private readonly onPermalinkClicked = (e: MouseEvent): void => { // This allows the permalink to be opened in a new tab/window or copied as // matrix.to, but also for it to enable routing within Element when clicked. e.preventDefault(); @@ -855,15 +855,34 @@ export class UnwrappedEventTile extends React.Component return null; } - private onActionBarFocusChange = (actionBarFocused: boolean): void => { - this.setState({ actionBarFocused }); + private readonly onActionBarFocusChange = (actionBarFocused: boolean): void => { + this.setState((prevState) => ({ + actionBarFocused, + hover: actionBarFocused ? prevState.hover : (this.ref.current?.matches(":hover") ?? false), + })); }; - private getTile: () => IEventTileType | null = () => this.tile.current; + private readonly onFocusWithin = (event: FocusEvent): void => { + // Show the action toolbar for keyboard-visible focus, with what-input as a fallback signal. + const target = event.target as HTMLElement; + const showActionBarFromFocus = + target.matches(":focus-visible") || document.body.dataset["data-whatinput"] === "keyboard"; + this.setState({ focusWithin: true, showActionBarFromFocus }); + }; - private getReplyChain = (): ReplyChain | null => this.replyChain.current; + private readonly onBlurWithin = (event: FocusEvent): void => { + if (event.currentTarget.contains(event.relatedTarget)) { + return; + } - private getReactions = (): Relations | null => { + this.setState({ focusWithin: false, showActionBarFromFocus: false }); + }; + + private readonly getTile: () => IEventTileType | null = () => this.tile.current; + + private readonly getReplyChain = (): ReplyChain | null => this.replyChain.current; + + private readonly getReactions = (): Relations | null => { if (!this.props.showReactions || !this.props.getRelationsForEvent) { return null; } @@ -871,7 +890,7 @@ export class UnwrappedEventTile extends React.Component return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction") ?? null; }; - private onReactionsCreated = (relationType: string, eventType: string): void => { + private readonly onReactionsCreated = (relationType: string, eventType: string): void => { if (relationType !== "m.annotation" || eventType !== "m.reaction") { return; } @@ -880,11 +899,11 @@ export class UnwrappedEventTile extends React.Component }); }; - private onContextMenu = (ev: React.MouseEvent): void => { + private readonly onContextMenu = (ev: React.MouseEvent): void => { this.showContextMenu(ev); }; - private onTimestampContextMenu = (ev: React.MouseEvent): void => { + private readonly onTimestampContextMenu = (ev: React.MouseEvent): void => { this.showContextMenu(ev, this.props.permalinkCreator?.forEvent(this.props.mxEvent.getId()!)); }; @@ -917,17 +936,19 @@ export class UnwrappedEventTile extends React.Component link: anchorElement?.href || permalink, }, actionBarFocused: true, + hover: false, }); } - private onCloseMenu = (): void => { + private readonly onCloseMenu = (): void => { this.setState({ contextMenu: undefined, actionBarFocused: false, + hover: false, }); }; - private setQuoteExpanded = (expanded: boolean): void => { + private readonly setQuoteExpanded = (expanded: boolean): void => { this.setState({ isQuoteExpanded: expanded, }); @@ -1150,9 +1171,14 @@ export class UnwrappedEventTile extends React.Component } } - const showMessageActionBar = !isEditing && !this.props.forExport; + const showMessageActionBar = + !isEditing && + !this.props.forExport && + (this.state.hover || + this.state.showActionBarFromFocus || + (this.state.actionBarFocused && !this.state.contextMenu)); const actionBar = showMessageActionBar ? ( - "data-event-id": this.props.mxEvent.getId(), "onMouseEnter": () => this.setState({ hover: true }), "onMouseLeave": () => this.setState({ hover: false }), - "onFocus": () => this.setState({ focusWithin: true }), - "onBlur": () => this.setState({ focusWithin: false }), + "onFocus": this.onFocusWithin, + "onBlur": this.onBlurWithin, }, [
@@ -1348,15 +1374,15 @@ export class UnwrappedEventTile extends React.Component "data-has-reply": !!replyChain, "onMouseEnter": () => this.setState({ hover: true }), "onMouseLeave": () => this.setState({ hover: false }), - "onFocus": () => this.setState({ focusWithin: true }), - "onBlur": () => this.setState({ focusWithin: false }), + "onFocus": this.onFocusWithin, + "onBlur": this.onBlurWithin, "onClick": (ev: MouseEvent) => { const target = ev.currentTarget as HTMLElement; let index = -1; if (target.parentElement) index = Array.from(target.parentElement.children).indexOf(target); switch (this.context.timelineRenderingType) { case TimelineRenderingType.Notification: - this.viewInRoom(ev); + this.onViewInRoomClick(null); break; case TimelineRenderingType.ThreadsList: dis.dispatch({ @@ -1411,9 +1437,9 @@ export class UnwrappedEventTile extends React.Component {this.renderThreadPanelSummary()}
{this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && ( - )} @@ -1481,8 +1507,8 @@ export class UnwrappedEventTile extends React.Component "data-has-reply": !!replyChain, "onMouseEnter": () => this.setState({ hover: true }), "onMouseLeave": () => this.setState({ hover: false }), - "onFocus": () => this.setState({ focusWithin: true }), - "onBlur": () => this.setState({ focusWithin: false }), + "onFocus": this.onFocusWithin, + "onBlur": this.onBlurWithin, }, <> {ircTimestamp} @@ -1861,3 +1887,160 @@ function ReactionsRowWrapper({ mxEvent, reactions }: Readonly ); } + +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; +} + +interface ThreadListActionBarWrapperProps { + onViewInRoomClick: (anchor: HTMLElement | null) => void; + onCopyLinkClick: (anchor: HTMLElement | null) => void | Promise; +} + +function ThreadListActionBarWrapper({ + onViewInRoomClick, + onCopyLinkClick, +}: Readonly): JSX.Element { + const vm = useCreateAutoDisposedViewModel( + () => + new ThreadListActionBarViewModel({ + onViewInRoomClick, + onCopyLinkClick, + }), + ); + + useEffect(() => { + vm.setProps({ + onViewInRoomClick, + onCopyLinkClick, + }); + }, [vm, onViewInRoomClick, onCopyLinkClick]); + + return ; +} + +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} + + ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx b/apps/web/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx deleted file mode 100644 index bd29b53fb7..0000000000 --- a/apps/web/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import React, { type JSX } from "react"; -import { LinkIcon, VisibilityOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; - -import { RovingAccessibleButton } from "../../../../accessibility/RovingTabIndex"; -import Toolbar from "../../../../accessibility/Toolbar"; -import { _t } from "../../../../languageHandler"; -import { type ButtonEvent } from "../../elements/AccessibleButton"; - -export function EventTileThreadToolbar({ - viewInRoom, - copyLinkToThread, -}: { - viewInRoom: (evt: ButtonEvent) => void; - copyLinkToThread: (evt: ButtonEvent) => void; -}): JSX.Element { - return ( - - - - - - - - - ); -} diff --git a/apps/web/src/hooks/useMediaVisible.ts b/apps/web/src/hooks/useMediaVisible.ts index de0b0fbf6d..58540be8fb 100644 --- a/apps/web/src/hooks/useMediaVisible.ts +++ b/apps/web/src/hooks/useMediaVisible.ts @@ -8,64 +8,49 @@ Please see LICENSE files in the repository root for full details. import { useCallback } from "react"; import { JoinRule, type MatrixEvent } from "matrix-js-sdk/src/matrix"; -import { SettingLevel } from "../settings/SettingLevel"; import { useSettingValue } from "./useSettings"; -import SettingsStore from "../settings/SettingsStore"; -import { useMatrixClientContext } from "../contexts/MatrixClientContext"; -import { MediaPreviewValue } from "../@types/media_preview"; import { useRoomState } from "./useRoomState"; - -const PRIVATE_JOIN_RULES: JoinRule[] = [JoinRule.Invite, JoinRule.Knock, JoinRule.Restricted]; +import { useMatrixClientContext } from "../contexts/MatrixClientContext"; +import { computeMediaVisibility, setMediaVisibility } from "../utils/media/mediaVisibility"; /** - * Should the media event be visible in the client, or hidden. + * Determine whether media for an event should be visible in the client and expose a setter for + * a per-event override. * - * This function uses the `mediaPreviewConfig` setting to determine the rules for the room - * along with the `showMediaEventIds` setting for specific events. + * Visibility is resolved from the effective `mediaPreviewConfig` setting together with any + * event-specific overrides stored in `showMediaEventIds`. * - * A function may be provided to alter the visible state. + * @param mxEvent - The event that contains the media. If omitted, visibility is derived from the + * current setting defaults and the returned setter is a no-op. * - * @param The event that contains the media. If not provided, the global rule is used. - * - * @returns Returns a tuple of: - * A boolean describing the hidden status. - * A function to show or hide the event. + * @returns A tuple containing the effective visibility for the event and a function that stores a + * device-local visibility override for that event. */ export function useMediaVisible(mxEvent?: MatrixEvent): [boolean, (visible: boolean) => void] { - const eventId = mxEvent?.getId(); - const mediaPreviewSetting = useSettingValue("mediaPreviewConfig", mxEvent?.getRoomId()); const client = useMatrixClientContext(); + const roomId = mxEvent?.getRoomId(); + const mediaPreviewSetting = useSettingValue("mediaPreviewConfig", roomId); const eventVisibility = useSettingValue("showMediaEventIds"); - const room = client.getRoom(mxEvent?.getRoomId()) ?? undefined; + const room = roomId ? (client.getRoom(roomId) ?? undefined) : undefined; const joinRule = useRoomState(room, (state) => state.getJoinRule()); + const setMediaVisible = useCallback( (visible: boolean) => { - SettingsStore.setValue("showMediaEventIds", null, SettingLevel.DEVICE, { - ...eventVisibility, - [eventId!]: visible, - }); + if (!mxEvent) return; + void setMediaVisibility(mxEvent, visible); }, - [eventId, eventVisibility], + [mxEvent], ); - const roomIsPrivate = joinRule ? PRIVATE_JOIN_RULES.includes(joinRule) : false; - - const explicitEventVisiblity = eventId ? eventVisibility[eventId] : undefined; - // Always prefer the explicit per-event user preference here. - if (explicitEventVisiblity !== undefined) { - return [explicitEventVisiblity, setMediaVisible]; - } else if (mxEvent?.getSender() === client.getUserId()) { - // If this event is ours and we've not set an explicit visibility, default to on. - return [true, setMediaVisible]; - } else if (mediaPreviewSetting.media_previews === MediaPreviewValue.Off) { - return [false, setMediaVisible]; - } else if (mediaPreviewSetting.media_previews === MediaPreviewValue.On) { - return [true, setMediaVisible]; - } else if (mediaPreviewSetting.media_previews === MediaPreviewValue.Private) { - return [roomIsPrivate, setMediaVisible]; - } else { - // Invalid setting. - console.warn("Invalid media visibility setting", mediaPreviewSetting.media_previews); - return [false, setMediaVisible]; - } + return [ + computeMediaVisibility( + mediaPreviewSetting, + eventVisibility, + client.getUserId() ?? undefined, + mxEvent?.getId(), + mxEvent?.getSender(), + joinRule ? [JoinRule.Invite, JoinRule.Knock, JoinRule.Restricted].includes(joinRule) : false, + ), + setMediaVisible, + ]; } diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index d56399dda4..187908a642 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -32,7 +32,6 @@ "cancel": "Cancel", "change": "Change", "clear": "Clear", - "click": "Click", "click_to_copy": "Click to copy", "close": "Close", "collapse": "Collapse", @@ -66,7 +65,6 @@ "go": "Go", "go_back": "Go back", "got_it": "Got it", - "hide": "Hide", "hide_advanced": "Hide advanced", "hold": "Hold", "ignore": "Ignore", @@ -1567,6 +1565,7 @@ "render_reaction_images_description": "Sometimes referred to as \"custom emojis\".", "report_to_moderators": "Report to moderators", "report_to_moderators_description": "In rooms that support moderation, the “Report” button will let you report abuse to room moderators.", + "room_list_sections": "Room list sections", "share_history_on_invite": "Share encrypted history with new members", "share_history_on_invite_description": "When inviting a user to an encrypted room that has history visibility set to \"shared\", share encrypted history with that user, and accept encrypted history when you are invited to such a room.", "share_history_on_invite_warning": "This feature is EXPERIMENTAL and not all security precautions are implemented. Do not enable on production accounts.", @@ -2164,6 +2163,11 @@ "one": "Currently removing messages in %(count)s room", "other": "Currently removing messages in %(count)s rooms" }, + "section": { + "chats": "Chats", + "favourites": "Favourites", + "low_priority": "Low Priority" + }, "show_less": "Show less", "show_n_more": { "one": "Show %(count)s more", @@ -3310,7 +3314,6 @@ }, "empty_description": "Use “%(replyInThread)s” when hovering over a message.", "empty_title": "Threads help keep your conversations on-topic and easy to track.", - "error_start_thread_existing_relation": "Can't create a thread from an event with an existing relation", "mark_all_read": "Mark all as read", "my_threads": "My threads", "my_threads_description": "Shows all threads you've participated in", @@ -3354,7 +3357,6 @@ "unable_to_decrypt": "Unable to decrypt message" }, "disambiguated_profile": "%(displayName)s (%(matrixId)s)", - "download_action_decrypting": "Decrypting", "download_action_downloading": "Downloading", "download_failed": "Download failed", "download_failed_description": "An error occurred while downloading this file", @@ -3554,10 +3556,7 @@ "removed": "%(widgetName)s widget removed by %(senderName)s" }, "mab": { - "collapse_reply_chain": "Collapse quotes", "copy_link_thread": "Copy link to thread", - "expand_reply_chain": "Expand quotes", - "label": "Message Actions", "view_in_room": "View in room" }, "mjolnir": { diff --git a/apps/web/src/settings/Settings.tsx b/apps/web/src/settings/Settings.tsx index 02a692a209..a3c1565bc8 100644 --- a/apps/web/src/settings/Settings.tsx +++ b/apps/web/src/settings/Settings.tsx @@ -223,6 +223,7 @@ export interface Settings { "feature_dynamic_room_predecessors": IFeature; "feature_render_reaction_images": IFeature; "feature_new_room_list": IFeature; + "feature_room_list_sections": IFeature; "feature_ask_to_join": IFeature; "feature_notifications": IFeature; "feature_msc4362_encrypted_state_events": IFeature; @@ -695,6 +696,15 @@ export const SETTINGS: Settings = { default: true, controller: new ReloadOnChangeController(), }, + "feature_room_list_sections": { + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED, + labsGroup: LabGroup.Ui, + displayName: _td("labs|room_list_sections"), + description: _td("labs|under_active_development"), + isFeature: true, + default: false, + controller: new ReloadOnChangeController(), + }, /** * With the transition to Compound we are moving to a base font size * of 16px. We're taking the opportunity to move away from the `baseFontSize` diff --git a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts index d8b9dd5f8f..71c5a1a9cb 100644 --- a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts @@ -11,11 +11,10 @@ import { EventType } from "matrix-js-sdk/src/matrix"; import type { EmptyObject, Room } from "matrix-js-sdk/src/matrix"; import type { MatrixDispatcher } from "../../dispatcher/dispatcher"; import type { ActionPayload } from "../../dispatcher/payloads"; -import type { FilterKey } from "./skip-list/filters"; +import type { Filter, FilterKey } from "./skip-list/filters"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import SettingsStore from "../../settings/SettingsStore"; import defaultDispatcher from "../../dispatcher/dispatcher"; -import { RoomSkipList } from "./skip-list/RoomSkipList"; import { RecencySorter } from "./skip-list/sorters/RecencySorter"; import { AlphabeticSorter } from "./skip-list/sorters/AlphabeticSorter"; import { readReceiptChangeIsFor } from "../../utils/read-receipts"; @@ -36,6 +35,11 @@ import { Action } from "../../dispatcher/actions"; import { UnreadSorter } from "./skip-list/sorters/UnreadSorter"; import { getChangedOverrideRoomMutePushRules } from "./utils"; import { isRoomVisible } from "./isRoomVisible"; +import { RoomSkipList } from "./skip-list/RoomSkipList"; +import { DefaultTagID } from "./skip-list/tag"; +import { ExcludeTagsFilter } from "./skip-list/filters/ExcludeTagsFilter"; +import { TagFilter } from "./skip-list/filters/TagFilter"; +import { filterBoolean } from "../../utils/arrays"; /** * These are the filters passed to the room skip list. @@ -64,9 +68,25 @@ export type RoomsResult = { // The filter queried filterKeys?: FilterKey[]; // The resulting list of rooms - rooms: Room[]; + sections: Section[]; }; +/** + * Represents a named section of rooms in the room list, identified by a tag. + */ +export interface Section { + /** The tag that identifies this section. */ + tag: string; + /** The ordered list of rooms belonging to this section. */ + rooms: Room[]; +} + +/** + * A synthetic tag used to represent the "Chats" section, which contains + * every room that does not belong to any other explicit tag section. + */ +export const CHATS_TAG = "chats"; + export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate; export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded; /** @@ -75,7 +95,21 @@ export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded; * This store is being actively developed so expect the methods to change in future. */ export class RoomListStoreV3Class extends AsyncStoreWithClient { + /** + * Contains all the rooms in the active space + */ private roomSkipList?: RoomSkipList; + + /** + * Maps section tags to their corresponding tag filters, used to determine which rooms belong in which sections. + */ + private readonly filterByTag: Map = new Map(); + + /** + * Defines the display order of sections. + */ + private readonly sortedTags: string[] = [DefaultTagID.Favourite, CHATS_TAG, DefaultTagID.LowPriority]; + private readonly msc3946ProcessDynamicPredecessor: boolean; /** @@ -126,13 +160,17 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { */ public getSortedRoomsInActiveSpace(filterKeys?: FilterKey[]): RoomsResult { const spaceId = SpaceStore.instance.activeSpace; - if (this.roomSkipList?.initialized) - return { - spaceId: spaceId, - filterKeys, - rooms: Array.from(this.roomSkipList.getRoomsInActiveSpace(filterKeys)), - }; - else return { spaceId: spaceId, filterKeys, rooms: [] }; + + const areSectionsEnabled = SettingsStore.getValue("feature_room_list_sections"); + const sections = areSectionsEnabled + ? this.getSections(filterKeys) + : [{ tag: CHATS_TAG, rooms: Array.from(this.roomSkipList?.getRoomsInActiveSpace(filterKeys) ?? []) }]; + + return { + spaceId: spaceId, + filterKeys, + sections, + }; } /** @@ -159,7 +197,9 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { protected async onReady(): Promise { if (this.roomSkipList?.initialized || !this.matrixClient) return; const sorter = this.getPreferredSorter(this.matrixClient.getSafeUserId()); - this.roomSkipList = new RoomSkipList(sorter, FILTERS); + + this.roomSkipList = new RoomSkipList(sorter, this.getSkipListFilters()); + await SpaceStore.instance.storeReadyPromise; const rooms = this.getRooms(); this.roomSkipList.seed(rooms); @@ -276,7 +316,6 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { const room = payload.room; this.roomSkipList.removeRoom(room); this.scheduleEmit(); - break; } } @@ -300,7 +339,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { logger.warn(`${roomId} was found in DMs but the room is not in the store`); continue; } - this.roomSkipList!.reInsertRoom(room); + this.roomSkipList?.reInsertRoom(room); needsEmit = true; } } @@ -314,7 +353,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { .map((id) => this.matrixClient?.getRoom(id)) .filter((room) => !!room); for (const room of rooms) { - this.roomSkipList!.reInsertRoom(room); + this.roomSkipList?.reInsertRoom(room); needsEmit = true; } break; @@ -395,6 +434,35 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { this.roomSkipList.calculateActiveSpaceForNodes(); this.scheduleEmit(); } + + /** + * Get the list of filters to be used in the skip list, including the tag filters for sectioning. + */ + private getSkipListFilters(): Filter[] { + const tagsToExclude = this.sortedTags.filter((tag) => tag !== CHATS_TAG); + const tagFilters = this.sortedTags.map((tag) => + tag === CHATS_TAG ? new ExcludeTagsFilter(tagsToExclude) : new TagFilter(tag), + ); + this.sortedTags.forEach((tag, index) => this.filterByTag.set(tag, tagFilters[index])); + + return [...FILTERS, ...tagFilters]; + } + + /** + * Get the sections to display in the room list, based on the current active space and the provided filters. + * @param filterKeys - Optional array of filters that the rooms must match against to be included in the sections. + * @returns An array of sections + */ + private getSections(filterKeys?: FilterKey[]): Section[] { + return this.sortedTags.map((tag) => { + const filters = filterBoolean([this.filterByTag.get(tag)?.key, ...(filterKeys || [])]); + + return { + tag, + rooms: Array.from(this.roomSkipList?.getRoomsInActiveSpace(filters) || []), + }; + }); + } } export default class RoomListStoreV3 { diff --git a/apps/web/src/stores/room-list-v3/skip-list/filters/ExcludeTagsFilter.ts b/apps/web/src/stores/room-list-v3/skip-list/filters/ExcludeTagsFilter.ts new file mode 100644 index 0000000000..b62cabd93b --- /dev/null +++ b/apps/web/src/stores/room-list-v3/skip-list/filters/ExcludeTagsFilter.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type Room } from "matrix-js-sdk/src/matrix"; + +import { type Filter, FilterEnum } from "."; + +export class ExcludeTagsFilter implements Filter { + public constructor(private readonly tags: string[]) {} + + public matches(room: Room): boolean { + return !this.tags.some((tag) => room.tags[tag]); + } + + public get key(): FilterEnum.ExcludeTagsFilter { + return FilterEnum.ExcludeTagsFilter; + } +} diff --git a/apps/web/src/stores/room-list-v3/skip-list/filters/FavouriteFilter.ts b/apps/web/src/stores/room-list-v3/skip-list/filters/FavouriteFilter.ts index 4dafcb21d8..e349054319 100644 --- a/apps/web/src/stores/room-list-v3/skip-list/filters/FavouriteFilter.ts +++ b/apps/web/src/stores/room-list-v3/skip-list/filters/FavouriteFilter.ts @@ -5,8 +5,7 @@ Please see LICENSE files in the repository root for full details. */ import type { Room } from "matrix-js-sdk/src/matrix"; -import type { Filter } from "."; -import { FilterKey } from "."; +import { FilterEnum, type Filter } from "."; import { DefaultTagID } from "../tag"; export class FavouriteFilter implements Filter { @@ -14,7 +13,7 @@ export class FavouriteFilter implements Filter { return !!room.tags[DefaultTagID.Favourite]; } - public get key(): FilterKey.FavouriteFilter { - return FilterKey.FavouriteFilter; + public get key(): FilterEnum.FavouriteFilter { + return FilterEnum.FavouriteFilter; } } diff --git a/apps/web/src/stores/room-list-v3/skip-list/filters/InvitesFilter.ts b/apps/web/src/stores/room-list-v3/skip-list/filters/InvitesFilter.ts index fb9fff9b44..fd767b40c8 100644 --- a/apps/web/src/stores/room-list-v3/skip-list/filters/InvitesFilter.ts +++ b/apps/web/src/stores/room-list-v3/skip-list/filters/InvitesFilter.ts @@ -6,15 +6,14 @@ Please see LICENSE files in the repository root for full details. import { type Room, KnownMembership } from "matrix-js-sdk/src/matrix"; -import type { Filter } from "."; -import { FilterKey } from "."; +import { type Filter, FilterEnum } from "."; export class InvitesFilter implements Filter { public matches(room: Room): boolean { return room.getMyMembership() === KnownMembership.Invite; } - public get key(): FilterKey.InvitesFilter { - return FilterKey.InvitesFilter; + public get key(): FilterEnum.InvitesFilter { + return FilterEnum.InvitesFilter; } } diff --git a/apps/web/src/stores/room-list-v3/skip-list/filters/LowPriorityFilter.ts b/apps/web/src/stores/room-list-v3/skip-list/filters/LowPriorityFilter.ts index 861d18fa50..cee870087d 100644 --- a/apps/web/src/stores/room-list-v3/skip-list/filters/LowPriorityFilter.ts +++ b/apps/web/src/stores/room-list-v3/skip-list/filters/LowPriorityFilter.ts @@ -5,8 +5,7 @@ Please see LICENSE files in the repository root for full details. */ import type { Room } from "matrix-js-sdk/src/matrix"; -import type { Filter } from "."; -import { FilterKey } from "."; +import { type Filter, FilterEnum } from "."; import { DefaultTagID } from "../tag"; export class LowPriorityFilter implements Filter { @@ -14,7 +13,7 @@ export class LowPriorityFilter implements Filter { return !!room.tags[DefaultTagID.LowPriority]; } - public get key(): FilterKey.LowPriorityFilter { - return FilterKey.LowPriorityFilter; + public get key(): FilterEnum.LowPriorityFilter { + return FilterEnum.LowPriorityFilter; } } diff --git a/apps/web/src/stores/room-list-v3/skip-list/filters/MentionsFilter.ts b/apps/web/src/stores/room-list-v3/skip-list/filters/MentionsFilter.ts index fb6e45b922..3f14978d5e 100644 --- a/apps/web/src/stores/room-list-v3/skip-list/filters/MentionsFilter.ts +++ b/apps/web/src/stores/room-list-v3/skip-list/filters/MentionsFilter.ts @@ -5,8 +5,7 @@ Please see LICENSE files in the repository root for full details. */ import type { Room } from "matrix-js-sdk/src/matrix"; -import type { Filter } from "."; -import { FilterKey } from "."; +import { type Filter, FilterEnum } from "."; import { RoomNotificationStateStore } from "../../../notifications/RoomNotificationStateStore"; export class MentionsFilter implements Filter { @@ -14,7 +13,7 @@ export class MentionsFilter implements Filter { return RoomNotificationStateStore.instance.getRoomState(room).isMention; } - public get key(): FilterKey.MentionsFilter { - return FilterKey.MentionsFilter; + public get key(): FilterEnum.MentionsFilter { + return FilterEnum.MentionsFilter; } } diff --git a/apps/web/src/stores/room-list-v3/skip-list/filters/PeopleFilter.ts b/apps/web/src/stores/room-list-v3/skip-list/filters/PeopleFilter.ts index 742eb40abe..a0dd922bdb 100644 --- a/apps/web/src/stores/room-list-v3/skip-list/filters/PeopleFilter.ts +++ b/apps/web/src/stores/room-list-v3/skip-list/filters/PeopleFilter.ts @@ -5,8 +5,7 @@ Please see LICENSE files in the repository root for full details. */ import type { Room } from "matrix-js-sdk/src/matrix"; -import type { Filter } from "."; -import { FilterKey } from "."; +import { type Filter, FilterEnum } from "."; import DMRoomMap from "../../../../utils/DMRoomMap"; export class PeopleFilter implements Filter { @@ -15,7 +14,7 @@ export class PeopleFilter implements Filter { return !!DMRoomMap.shared().getUserIdForRoomId(room.roomId); } - public get key(): FilterKey.PeopleFilter { - return FilterKey.PeopleFilter; + public get key(): FilterEnum.PeopleFilter { + return FilterEnum.PeopleFilter; } } diff --git a/apps/web/src/stores/room-list-v3/skip-list/filters/RoomsFilter.ts b/apps/web/src/stores/room-list-v3/skip-list/filters/RoomsFilter.ts index 58349dcea2..dc49cde05b 100644 --- a/apps/web/src/stores/room-list-v3/skip-list/filters/RoomsFilter.ts +++ b/apps/web/src/stores/room-list-v3/skip-list/filters/RoomsFilter.ts @@ -5,8 +5,7 @@ Please see LICENSE files in the repository root for full details. */ import type { Room } from "matrix-js-sdk/src/matrix"; -import type { Filter } from "."; -import { FilterKey } from "."; +import { type Filter, FilterEnum } from "."; import DMRoomMap from "../../../../utils/DMRoomMap"; export class RoomsFilter implements Filter { @@ -15,7 +14,7 @@ export class RoomsFilter implements Filter { return !DMRoomMap.shared().getUserIdForRoomId(room.roomId); } - public get key(): FilterKey.RoomsFilter { - return FilterKey.RoomsFilter; + public get key(): FilterEnum.RoomsFilter { + return FilterEnum.RoomsFilter; } } diff --git a/apps/web/src/stores/room-list-v3/skip-list/filters/TagFilter.ts b/apps/web/src/stores/room-list-v3/skip-list/filters/TagFilter.ts new file mode 100644 index 0000000000..9947f8be51 --- /dev/null +++ b/apps/web/src/stores/room-list-v3/skip-list/filters/TagFilter.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type Room } from "matrix-js-sdk/src/matrix"; + +import { type Filter } from "."; + +export class TagFilter implements Filter { + public constructor(private readonly tag: string) {} + + public matches(room: Room): boolean { + return !!room.tags[this.tag]; + } + + public get key(): string { + return this.tag; + } +} diff --git a/apps/web/src/stores/room-list-v3/skip-list/filters/UnreadFilter.ts b/apps/web/src/stores/room-list-v3/skip-list/filters/UnreadFilter.ts index db29861cd1..0c6100eb13 100644 --- a/apps/web/src/stores/room-list-v3/skip-list/filters/UnreadFilter.ts +++ b/apps/web/src/stores/room-list-v3/skip-list/filters/UnreadFilter.ts @@ -5,8 +5,7 @@ Please see LICENSE files in the repository root for full details. */ import type { Room } from "matrix-js-sdk/src/matrix"; -import type { Filter } from "."; -import { FilterKey } from "."; +import { type Filter, FilterEnum } from "."; import { RoomNotificationStateStore } from "../../../notifications/RoomNotificationStateStore"; import { getMarkedUnreadState } from "../../../../utils/notifications"; @@ -15,7 +14,7 @@ export class UnreadFilter implements Filter { return RoomNotificationStateStore.instance.getRoomState(room).hasUnreadCount || !!getMarkedUnreadState(room); } - public get key(): FilterKey.UnreadFilter { - return FilterKey.UnreadFilter; + public get key(): FilterEnum.UnreadFilter { + return FilterEnum.UnreadFilter; } } diff --git a/apps/web/src/stores/room-list-v3/skip-list/filters/index.ts b/apps/web/src/stores/room-list-v3/skip-list/filters/index.ts index e4c65167b3..b66c2dd0ad 100644 --- a/apps/web/src/stores/room-list-v3/skip-list/filters/index.ts +++ b/apps/web/src/stores/room-list-v3/skip-list/filters/index.ts @@ -6,16 +6,19 @@ Please see LICENSE files in the repository root for full details. import type { Room } from "matrix-js-sdk/src/matrix"; -export const enum FilterKey { - FavouriteFilter, - UnreadFilter, - PeopleFilter, - RoomsFilter, - LowPriorityFilter, - MentionsFilter, - InvitesFilter, +export const enum FilterEnum { + FavouriteFilter = "favourite", + UnreadFilter = "unread", + PeopleFilter = "people", + RoomsFilter = "rooms", + LowPriorityFilter = "low_priority", + MentionsFilter = "mentions", + InvitesFilter = "invites", + ExcludeTagsFilter = "exclude_tags", } +export type FilterKey = FilterEnum | string; + export interface Filter { /** * Boolean return value indicates whether this room satisfies diff --git a/apps/web/src/stores/widgets/ElementWidgetDriver.ts b/apps/web/src/stores/widgets/ElementWidgetDriver.ts index 157991e860..23ab7cc511 100644 --- a/apps/web/src/stores/widgets/ElementWidgetDriver.ts +++ b/apps/web/src/stores/widgets/ElementWidgetDriver.ts @@ -128,6 +128,7 @@ export class ElementWidgetDriver extends WidgetDriver { this.allowedCapabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent); this.allowedCapabilities.add(MatrixCapabilities.MSC4407SendStickyEvent); this.allowedCapabilities.add(MatrixCapabilities.MSC4407ReceiveStickyEvent); + this.allowedCapabilities.add(MatrixCapabilities.MSC4039DownloadFile); this.allowedCapabilities.add( WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomName).raw, diff --git a/apps/web/src/utils/media/mediaVisibility.ts b/apps/web/src/utils/media/mediaVisibility.ts new file mode 100644 index 0000000000..daebd52715 --- /dev/null +++ b/apps/web/src/utils/media/mediaVisibility.ts @@ -0,0 +1,122 @@ +/* +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 { JoinRule, type MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { type MediaPreviewConfig, MediaPreviewValue } from "../../@types/media_preview"; +import { SettingLevel } from "../../settings/SettingLevel"; +import SettingsStore from "../../settings/SettingsStore"; + +/** + * Determine whether a room should be treated as private when applying media preview defaults. + * + * @param client - Matrix client used to resolve the room and its current join rule. + * @param roomId - Room to inspect. If omitted or unknown, the room is treated as non-private. + * @returns `true` when the room's join rule restricts membership, otherwise `false`. + */ +function isRoomPrivate(client: MatrixClient, roomId?: string): boolean { + const room = roomId ? client.getRoom(roomId) : undefined; + const joinRule = room?.currentState.getJoinRule(); + + switch (joinRule) { + case JoinRule.Invite: + case JoinRule.Knock: + case JoinRule.Restricted: + return true; + default: + return false; + } +} + +/** + * Resolve whether media for a single event should be shown. + * + * Precedence is: + * 1. An explicit per-event override stored in `showMediaEventIds` + * 2. Always show media in events sent by the current user + * 3. Fall back to the room-level `mediaPreviewConfig` policy + * + * @param mediaPreviewSetting - Effective room-level media preview configuration. + * @param eventVisibility - Per-event visibility overrides keyed by event ID. + * @param userId - Current user ID, used to always show media sent by the local user. + * @param eventId - Event being evaluated. Used to look up any explicit override. + * @param sender - Sender of the event being evaluated. + * @param roomIsPrivate - Whether the event's room should use the private-room preview behavior. + * @returns `true` when media should be displayed for the event, otherwise `false`. + */ +export function computeMediaVisibility( + mediaPreviewSetting: MediaPreviewConfig, + eventVisibility: Record, + userId: string | undefined, + eventId: string | undefined, + sender: string | undefined, + roomIsPrivate: boolean, +): boolean { + const explicitEventVisibility = eventId ? eventVisibility[eventId] : undefined; + + if (explicitEventVisibility !== undefined) { + return explicitEventVisibility; + } + + if (sender === userId) { + return true; + } + + switch (mediaPreviewSetting.media_previews) { + case MediaPreviewValue.Off: + return false; + case MediaPreviewValue.On: + return true; + case MediaPreviewValue.Private: + return roomIsPrivate; + default: + console.warn("Invalid media visibility setting", mediaPreviewSetting.media_previews); + return false; + } +} + +/** + * Compute the effective media visibility for a Matrix event using the current settings state. + * + * @param mxEvent - Event whose media visibility should be evaluated. + * @param client - Matrix client used to resolve the current user and room metadata. + * @returns `true` when media should be shown for the event, otherwise `false`. + */ +export function getMediaVisibility(mxEvent: MatrixEvent, client: MatrixClient): boolean { + const eventId = mxEvent.getId(); + const roomId = mxEvent.getRoomId(); + const mediaPreviewSetting = SettingsStore.getValue("mediaPreviewConfig", roomId); + const eventVisibility = SettingsStore.getValue("showMediaEventIds"); + + return computeMediaVisibility( + mediaPreviewSetting, + eventVisibility, + client.getUserId() ?? undefined, + eventId, + mxEvent.getSender(), + isRoomPrivate(client, roomId), + ); +} + +/** + * Persist a per-event override for whether media should be displayed on this device. + * + * @param mxEvent - Event whose media visibility override should be updated. + * @param visible - Whether media for the event should be shown. + * @returns A promise that resolves once the device-scoped setting has been updated. + */ +export async function setMediaVisibility(mxEvent: MatrixEvent, visible: boolean): Promise { + const eventId = mxEvent.getId(); + if (!eventId) return; + + const eventVisibility = SettingsStore.getValue("showMediaEventIds"); + + await SettingsStore.setValue("showMediaEventIds", null, SettingLevel.DEVICE, { + ...eventVisibility, + [eventId]: visible, + }); +} diff --git a/apps/web/src/viewmodels/message-body/EditHistoryActionBarViewModel.ts b/apps/web/src/viewmodels/message-body/EditHistoryActionBarViewModel.ts new file mode 100644 index 0000000000..b0e2848c80 --- /dev/null +++ b/apps/web/src/viewmodels/message-body/EditHistoryActionBarViewModel.ts @@ -0,0 +1,75 @@ +/* + * 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 { + ActionBarAction, + BaseViewModel, + type ActionBarViewActions, + type ActionBarViewSnapshot, +} from "@element-hq/web-shared-components"; + +/** Props for the edit-history action bar view model. */ +export interface EditHistoryActionBarViewModelProps { + /** Whether to include the remove action. */ + canRemove: boolean; + /** Whether to include the view source action. */ + showViewSource: boolean; + /** Called when the remove action is activated. */ + onRemoveClick?: (anchor: HTMLElement | null) => void; + /** Called when the view source action is activated. */ + onViewSourceClick?: (anchor: HTMLElement | null) => void; +} + +/** View model for the label-style action bar shown in the edit-history panel. */ +export class EditHistoryActionBarViewModel + extends BaseViewModel + implements ActionBarViewActions +{ + public constructor(props: EditHistoryActionBarViewModelProps) { + super(props, EditHistoryActionBarViewModel.buildSnapshot(props)); + } + + private static buildSnapshot(props: EditHistoryActionBarViewModelProps): ActionBarViewSnapshot { + const actions: ActionBarAction[] = []; + + if (props.canRemove) { + actions.push(ActionBarAction.Remove); + } + if (props.showViewSource) { + actions.push(ActionBarAction.ViewSource); + } + + return { + actions, + presentation: "label", + isDownloadEncrypted: false, + isDownloadLoading: false, + isPinned: false, + isQuoteExpanded: false, + isThreadReplyAllowed: true, + }; + } + + /** Updates props and rebuilds the derived action-bar snapshot. */ + public setProps(newProps: Partial): void { + this.props = { + ...this.props, + ...newProps, + }; + this.snapshot.merge(EditHistoryActionBarViewModel.buildSnapshot(this.props)); + } + + /** Forwards the remove action using the triggering button as the anchor. */ + public onRemoveClick = (anchor: HTMLElement | null): void => { + this.props.onRemoveClick?.(anchor); + }; + + /** Forwards the view source action using the triggering button as the anchor. */ + public onViewSourceClick = (anchor: HTMLElement | null): void => { + this.props.onViewSourceClick?.(anchor); + }; +} diff --git a/apps/web/src/viewmodels/message-body/VideoBodyViewModel.ts b/apps/web/src/viewmodels/message-body/VideoBodyViewModel.ts new file mode 100644 index 0000000000..95b0226bb6 --- /dev/null +++ b/apps/web/src/viewmodels/message-body/VideoBodyViewModel.ts @@ -0,0 +1,543 @@ +/* + * 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 { decode } from "blurhash"; +import { type RefObject } from "react"; +import { logger } from "matrix-js-sdk/src/logger"; +import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { type MediaEventContent, type VideoInfo } from "matrix-js-sdk/src/types"; +import { + BaseViewModel, + VideoBodyViewState, + type VideoBodyViewModel as VideoBodyViewModelInterface, + type VideoBodyViewSnapshot, +} from "@element-hq/web-shared-components"; + +import { _t } from "../../languageHandler"; +import SettingsStore from "../../settings/SettingsStore"; +import { mediaFromContent } from "../../customisations/Media"; +import { BLURHASH_FIELD } from "../../utils/image-media"; +import { type ImageSize, suggestedSize as suggestedVideoSize } from "../../settings/enums/ImageSize"; +import { type MediaEventHelper } from "../../utils/MediaEventHelper"; + +export interface VideoBodyViewModelProps { + /** + * Video event being rendered. + */ + mxEvent: MatrixEvent; + /** + * Helper for resolving encrypted and unencrypted media sources. + */ + mediaEventHelper?: MediaEventHelper; + /** + * Whether the video is being rendered for export instead of live playback. + */ + forExport?: boolean; + /** + * Whether playback controls and autoplay should be disabled. + */ + inhibitInteraction?: boolean; + /** + * Whether the media should currently be shown instead of the preview button. + */ + mediaVisible: boolean; + /** + * Callback invoked when the hidden-media preview is revealed. + */ + onPreviewClick?: () => void; + /** + * Ref to the underlying video element used for replay after lazy decryption. + */ + videoRef: RefObject; +} + +interface InternalState { + /** + * Decrypted playable media URL for encrypted videos. + */ + decryptedUrl: string | null; + /** + * Decrypted thumbnail URL for encrypted videos. + */ + decryptedThumbnailUrl: string | null; + /** + * Decrypted media blob cached for download or replay. + */ + decryptedBlob: Blob | null; + /** + * Last media-processing error, if any. + */ + error: unknown | null; + /** + * Whether an on-demand media fetch is in progress. + */ + fetchingData: boolean; + /** + * Whether the blurhash poster is being shown while the real poster loads. + */ + posterLoading: boolean; + /** + * Data URL generated from the blurhash placeholder. + */ + blurhashUrl: string | null; + /** + * Current media sizing preference from settings. + */ + imageSize: ImageSize; +} + +type VideoInfoWithBlurhash = VideoInfo & { + [BLURHASH_FIELD]?: string; +}; + +/** + * View model for the video message body, encapsulating media-loading and playback state. + */ +export class VideoBodyViewModel + extends BaseViewModel + implements VideoBodyViewModelInterface +{ + private state: InternalState; + + public constructor(props: VideoBodyViewModelProps) { + const initialState = VideoBodyViewModel.createInitialState(); + super(props, VideoBodyViewModel.computeSnapshot(props, initialState)); + this.state = initialState; + + const imageSizeWatcherRef = SettingsStore.watchSetting("Images.size", null, (_s, _r, _l, _nvl, value) => { + this.setImageSize(value as ImageSize); + }); + this.disposables.track(() => SettingsStore.unwatchSetting(imageSizeWatcherRef)); + } + + public loadInitialMediaIfVisible(): void { + if (this.props.mediaVisible) { + void this.downloadVideo(); + } + } + + private static createInitialState(): InternalState { + return { + fetchingData: false, + decryptedUrl: null, + decryptedThumbnailUrl: null, + decryptedBlob: null, + error: null, + posterLoading: false, + blurhashUrl: null, + imageSize: SettingsStore.getValue("Images.size") as ImageSize, + }; + } + + /** + * Derive the aspect ratio for the video frame from the event metadata, when available. + */ + private static getAspectRatio(mxEvent: MatrixEvent): string | undefined { + const { w, h } = (mxEvent.getContent().info as VideoInfoWithBlurhash | undefined) ?? {}; + if (!w || !h) { + return undefined; + } + + return `${w}/${h}`; + } + + /** + * Compute the rendered video dimensions from the event metadata and current image-size setting. + */ + private static getDimensions(mxEvent: MatrixEvent, imageSize: ImageSize): Required<{ w?: number; h?: number }> { + const { w, h } = (mxEvent.getContent().info as VideoInfoWithBlurhash | undefined) ?? {}; + return suggestedVideoSize(imageSize, { w, h }); + } + + /** + * Resolve the current playable video source URL for the event. + */ + private static getContentUrl(props: VideoBodyViewModelProps, state: InternalState): string | undefined { + const content = props.mxEvent.getContent(); + if (props.forExport) { + return content.file?.url ?? content.url; + } + + const media = mediaFromContent(content); + if (media.isEncrypted) { + return state.decryptedUrl ?? undefined; + } + + return media.srcHttp ?? undefined; + } + + /** + * Resolve the best thumbnail or poster URL for the current video state. + */ + private static getThumbnailUrl(props: VideoBodyViewModelProps, state: InternalState): string | null { + if (props.forExport) { + return null; + } + + const content = props.mxEvent.getContent(); + const media = mediaFromContent(content); + + if (media.isEncrypted && state.decryptedThumbnailUrl) { + return state.decryptedThumbnailUrl; + } + if (state.posterLoading) { + return state.blurhashUrl; + } + if (media.hasThumbnail) { + return media.thumbnailHttp; + } + + return null; + } + + private static computeSnapshot(props: VideoBodyViewModelProps, state: InternalState): VideoBodyViewSnapshot { + const content = props.mxEvent.getContent(); + const autoplay = !props.inhibitInteraction && (SettingsStore.getValue("autoplayVideo") as boolean); + const aspectRatio = VideoBodyViewModel.getAspectRatio(props.mxEvent); + const { w: maxWidth, h: maxHeight } = VideoBodyViewModel.getDimensions(props.mxEvent, state.imageSize); + + if (state.error !== null) { + return { + state: VideoBodyViewState.ERROR, + errorLabel: _t("timeline|m.video|error_decrypting"), + maxWidth, + maxHeight, + aspectRatio, + }; + } + + if (!props.mediaVisible) { + return { + state: VideoBodyViewState.HIDDEN, + hiddenButtonLabel: _t("timeline|m.video|show_video"), + maxWidth, + maxHeight, + aspectRatio, + }; + } + + if (!props.forExport && content.file !== undefined && state.decryptedUrl === null && autoplay) { + return { + state: VideoBodyViewState.LOADING, + maxWidth, + maxHeight, + aspectRatio, + }; + } + + const thumbnailUrl = VideoBodyViewModel.getThumbnailUrl(props, state); + let preload: VideoBodyViewSnapshot["preload"] = "metadata"; + let poster: string | undefined; + if (content.info && thumbnailUrl) { + preload = "none"; + poster = thumbnailUrl; + } + + return { + state: VideoBodyViewState.READY, + videoLabel: content.body, + videoTitle: content.body, + maxWidth, + maxHeight, + aspectRatio, + src: VideoBodyViewModel.getContentUrl(props, state), + poster, + preload, + controls: !props.inhibitInteraction, + muted: autoplay, + autoPlay: autoplay, + }; + } + + private updateSnapshotFromState(): void { + this.snapshot.set(VideoBodyViewModel.computeSnapshot(this.props, this.state)); + } + + private hasContentUrl(): boolean { + const url = VideoBodyViewModel.getContentUrl(this.props, this.state); + return !!url && !url.startsWith("data:"); + } + + private setImageSize(imageSize: ImageSize): void { + if (this.state.imageSize === imageSize) { + return; + } + + this.state = { + ...this.state, + imageSize, + }; + this.updateSnapshotFromState(); + } + + private resetMediaState(): void { + this.state = { + ...this.state, + decryptedUrl: null, + decryptedThumbnailUrl: null, + decryptedBlob: null, + error: null, + fetchingData: false, + posterLoading: false, + blurhashUrl: null, + }; + } + + private loadBlurhash(): void { + const info = this.props.mxEvent.getContent().info as VideoInfoWithBlurhash | undefined; + const blurhash = info?.[BLURHASH_FIELD]; + if (!blurhash) { + return; + } + + const canvas = document.createElement("canvas"); + const { w: width, h: height } = VideoBodyViewModel.getDimensions(this.props.mxEvent, this.state.imageSize); + + canvas.width = width; + canvas.height = height; + + const pixels = decode(blurhash, width, height); + const ctx = canvas.getContext("2d"); + if (!ctx) { + return; + } + + const imgData = ctx.createImageData(width, height); + imgData.data.set(pixels); + ctx.putImageData(imgData, 0, 0); + + this.state = { + ...this.state, + blurhashUrl: canvas.toDataURL(), + posterLoading: true, + }; + this.updateSnapshotFromState(); + + const media = mediaFromContent(this.props.mxEvent.getContent()); + if (!media.hasThumbnail || !media.thumbnailHttp) { + return; + } + + const currentEvent = this.props.mxEvent; + const image = new Image(); + image.onload = (): void => { + if (this.isDisposed || currentEvent !== this.props.mxEvent || !this.state.posterLoading) { + return; + } + + this.state = { + ...this.state, + posterLoading: false, + }; + this.updateSnapshotFromState(); + }; + image.src = media.thumbnailHttp; + } + + private async downloadVideo(): Promise { + try { + this.loadBlurhash(); + } catch (error) { + logger.error("Failed to load blurhash", error); + } + + if (!this.props.mediaEventHelper?.media.isEncrypted || this.state.decryptedUrl !== null) { + return; + } + + const currentEvent = this.props.mxEvent; + const currentHelper = this.props.mediaEventHelper; + try { + const autoplay = !this.props.inhibitInteraction && (SettingsStore.getValue("autoplayVideo") as boolean); + const thumbnailUrl = await currentHelper.thumbnailUrl.value; + + if ( + this.isDisposed || + currentEvent !== this.props.mxEvent || + currentHelper !== this.props.mediaEventHelper + ) { + return; + } + + if (autoplay) { + logger.log("Preloading video"); + this.state = { + ...this.state, + decryptedUrl: await currentHelper.sourceUrl.value, + decryptedThumbnailUrl: thumbnailUrl, + decryptedBlob: await currentHelper.sourceBlob.value, + }; + } else { + logger.log("NOT preloading video"); + const content = currentEvent.getContent(); + let mimetype = content.info?.mimetype ?? "application/octet-stream"; + if (mimetype === "video/quicktime") { + mimetype = "video/mp4"; + } + + this.state = { + ...this.state, + decryptedUrl: `data:${mimetype},`, + decryptedThumbnailUrl: thumbnailUrl || `data:${mimetype},`, + decryptedBlob: null, + }; + } + + this.updateSnapshotFromState(); + } catch (error) { + if ( + this.isDisposed || + currentEvent !== this.props.mxEvent || + currentHelper !== this.props.mediaEventHelper + ) { + return; + } + + logger.warn("Unable to decrypt attachment: ", error); + this.state = { + ...this.state, + error, + }; + this.updateSnapshotFromState(); + } + } + + public setEvent(mxEvent: MatrixEvent, mediaEventHelper?: MediaEventHelper): void { + if (this.props.mxEvent === mxEvent && this.props.mediaEventHelper === mediaEventHelper) { + return; + } + + this.props = { + ...this.props, + mxEvent, + mediaEventHelper, + }; + this.resetMediaState(); + this.updateSnapshotFromState(); + + if (this.props.mediaVisible) { + void this.downloadVideo(); + } + } + + public setForExport(forExport?: boolean): void { + if (this.props.forExport === forExport) { + return; + } + + this.props = { + ...this.props, + forExport, + }; + this.updateSnapshotFromState(); + } + + public setInhibitInteraction(inhibitInteraction?: boolean): void { + if (this.props.inhibitInteraction === inhibitInteraction) { + return; + } + + this.props = { + ...this.props, + inhibitInteraction, + }; + this.updateSnapshotFromState(); + } + + public setMediaVisible(mediaVisible: boolean): void { + if (this.props.mediaVisible === mediaVisible) { + return; + } + + this.props = { + ...this.props, + mediaVisible, + }; + this.updateSnapshotFromState(); + + if (mediaVisible) { + void this.downloadVideo(); + } + } + + public setOnPreviewClick(onPreviewClick?: () => void): void { + if (this.props.onPreviewClick === onPreviewClick) { + return; + } + + this.props = { + ...this.props, + onPreviewClick, + }; + } + + public onPreviewClick = (): void => { + this.props.onPreviewClick?.(); + }; + + public onPlay = async (): Promise => { + if (this.hasContentUrl() || this.state.fetchingData || this.state.error !== null) { + return; + } + + this.state = { + ...this.state, + fetchingData: true, + }; + + if (!this.props.mediaEventHelper?.media.isEncrypted) { + this.state = { + ...this.state, + error: "No file given in content", + fetchingData: false, + }; + this.updateSnapshotFromState(); + return; + } + + const currentEvent = this.props.mxEvent; + const currentHelper = this.props.mediaEventHelper; + + try { + const decryptedUrl = await currentHelper.sourceUrl.value; + const decryptedBlob = await currentHelper.sourceBlob.value; + + if ( + this.isDisposed || + currentEvent !== this.props.mxEvent || + currentHelper !== this.props.mediaEventHelper + ) { + return; + } + + this.state = { + ...this.state, + decryptedUrl, + decryptedBlob, + fetchingData: false, + }; + this.updateSnapshotFromState(); + this.props.videoRef.current?.play(); + } catch (error) { + if ( + this.isDisposed || + currentEvent !== this.props.mxEvent || + currentHelper !== this.props.mediaEventHelper + ) { + return; + } + + logger.warn("Unable to decrypt attachment: ", error); + this.state = { + ...this.state, + error, + fetchingData: false, + }; + this.updateSnapshotFromState(); + } + }; +} diff --git a/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts new file mode 100644 index 0000000000..5b8006e252 --- /dev/null +++ b/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts @@ -0,0 +1,40 @@ +/* + * 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 { + BaseViewModel, + type RoomListSectionHeaderActions, + type RoomListSectionHeaderViewSnapshot, +} from "@element-hq/web-shared-components"; + +interface RoomListSectionHeaderViewModelProps { + tag: string; + title: string; + onToggleExpanded: (isExpanded: boolean) => void; +} + +export class RoomListSectionHeaderViewModel + extends BaseViewModel + implements RoomListSectionHeaderActions +{ + public constructor(props: RoomListSectionHeaderViewModelProps) { + super(props, { id: props.tag, title: props.title, isExpanded: true }); + } + + public onClick = (): void => { + const isExpanded = !this.snapshot.current.isExpanded; + this.snapshot.merge({ isExpanded }); + this.props.onToggleExpanded(isExpanded); + }; + + /** + * Whether the section is currently expanded or not. + */ + public get isExpanded(): boolean { + return this.snapshot.current.isExpanded; + } +} diff --git a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts index 3ed45fc663..ffe30748d8 100644 --- a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts @@ -11,6 +11,8 @@ import { type FilterId, type RoomListViewActions, type RoomListViewState, + type RoomListSection, + _t, } from "@element-hq/web-shared-components"; import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; @@ -19,43 +21,75 @@ import dispatcher from "../../dispatcher/dispatcher"; import { type ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload"; import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import SpaceStore from "../../stores/spaces/SpaceStore"; -import RoomListStoreV3, { RoomListStoreV3Event, type RoomsResult } from "../../stores/room-list-v3/RoomListStoreV3"; -import { FilterKey } from "../../stores/room-list-v3/skip-list/filters"; +import RoomListStoreV3, { + CHATS_TAG, + RoomListStoreV3Event, + type RoomsResult, + type Section, +} from "../../stores/room-list-v3/RoomListStoreV3"; +import { FilterEnum } from "../../stores/room-list-v3/skip-list/filters"; import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; import { RoomListItemViewModel } from "./RoomListItemViewModel"; import { SdkContextClass } from "../../contexts/SDKContext"; import { hasCreateRoomRights } from "./utils"; import { keepIfSame } from "../../utils/keepIfSame"; +import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag"; +import { RoomListSectionHeaderViewModel } from "./RoomListSectionHeaderViewModel"; +import SettingsStore from "../../settings/SettingsStore"; + +/** + * Tracks the position of the active room within a specific section. + * Used to implement sticky room behaviour so the selected room doesn't + * jump around when the room list is re-sorted. + */ +interface StickyRoomPosition { + /** The tag of the section the room belongs to. */ + sectionTag: string; + /** The index of the room within that section. */ + indexInSection: number; +} interface RoomListViewModelProps { client: MatrixClient; } -const filterKeyToIdMap: Map = new Map([ - [FilterKey.UnreadFilter, "unread"], - [FilterKey.PeopleFilter, "people"], - [FilterKey.RoomsFilter, "rooms"], - [FilterKey.FavouriteFilter, "favourite"], - [FilterKey.MentionsFilter, "mentions"], - [FilterKey.InvitesFilter, "invites"], - [FilterKey.LowPriorityFilter, "low_priority"], +const filterKeyToIdMap: Map = new Map([ + [FilterEnum.UnreadFilter, "unread"], + [FilterEnum.PeopleFilter, "people"], + [FilterEnum.RoomsFilter, "rooms"], + [FilterEnum.FavouriteFilter, "favourite"], + [FilterEnum.MentionsFilter, "mentions"], + [FilterEnum.InvitesFilter, "invites"], + [FilterEnum.LowPriorityFilter, "low_priority"], ]); +const TAG_TO_TITLE_MAP: Record = { + [DefaultTagID.Favourite]: _t("room_list|section|favourites"), + [CHATS_TAG]: _t("room_list|section|chats"), + [DefaultTagID.LowPriority]: _t("room_list|section|low_priority"), +}; + export class RoomListViewModel extends BaseViewModel implements RoomListViewActions { // State tracking - private activeFilter: FilterKey | undefined = undefined; + private activeFilter: FilterEnum | undefined = undefined; private roomsResult: RoomsResult; - private lastActiveRoomIndex: number | undefined = undefined; + /** + * List of sections to display in the room list, derived from roomsResult and section header view model expansion state. + */ + private sections: Section[] = []; + private lastActiveRoomPosition: StickyRoomPosition | undefined = undefined; // Child view model management - private roomItemViewModels = new Map(); + private readonly roomItemViewModels = new Map(); // This map is intentionally additive (never cleared except on space changes) to avoid a race condition: // a list update can refresh roomsResult and roomsMap before the view re-renders, so the view may still // request a view model for a room that was removed from the latest list. Keeping old entries prevents a crash. private roomsMap = new Map(); + // Don't clear section vm because we want to keep the expand/collapse state even during space changes. + private readonly roomSectionHeaderViewModels = new Map(); public constructor(props: RoomListViewModelProps) { const activeSpace = SpaceStore.instance.activeSpaceRoom; @@ -63,14 +97,21 @@ export class RoomListViewModel // Get initial rooms const roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(undefined); const canCreateRoom = hasCreateRoomRights(props.client, activeSpace); - const filterIds = [...filterKeyToIdMap.values()]; - const roomIds = roomsResult.rooms.map((room) => room.roomId); - const sections = [{ id: "all", roomIds }]; + + // Remove favourite and low priority filters if sections are enabled, as they are redundant with the sections + const areSectionsEnabled = SettingsStore.getValue("feature_room_list_sections"); + const filterIds = [...filterKeyToIdMap.values()].filter( + (id) => !areSectionsEnabled || (id !== "favourite" && id !== "low_priority"), + ); + + // By default, all sections are expanded + const { sections, isFlatList } = computeSections(roomsResult, (tag) => true); + const isRoomListEmpty = roomsResult.sections.every((section) => section.rooms.length === 0); super(props, { // Initial view state - start with empty, will populate in async init isLoadingRooms: RoomListStoreV3.instance.isLoadingRooms, - isRoomListEmpty: roomsResult.rooms.length === 0, + isRoomListEmpty, filterIds, activeFilterId: undefined, roomListState: { @@ -78,13 +119,13 @@ export class RoomListViewModel spaceId: roomsResult.spaceId, filterKeys: undefined, }, - // Until we implement sections, this view model only supports the flat list mode - isFlatList: true, - sections, + isFlatList, + sections: toRoomListSection(sections), canCreateRoom, }); this.roomsResult = roomsResult; + this.sections = sections; // Build initial roomsMap from roomsResult this.updateRoomsMap(roomsResult); @@ -120,7 +161,7 @@ export class RoomListViewModel public onToggleFilter = (filterId: FilterId): void => { // Find the FilterKey by matching the filter ID - let filterKey: FilterKey | undefined = undefined; + let filterKey: FilterEnum | undefined = undefined; for (const [key, id] of filterKeyToIdMap.entries()) { if (id === filterId) { filterKey = key; @@ -150,7 +191,7 @@ export class RoomListViewModel * This maintains a quick lookup for room objects. */ private updateRoomsMap(roomsResult: RoomsResult): void { - for (const room of roomsResult.rooms) { + for (const room of roomsResult.sections.flatMap((section) => section.rooms)) { this.roomsMap.set(room.roomId, room); } } @@ -170,7 +211,7 @@ export class RoomListViewModel * Get the ordered list of room IDs. */ public get roomIds(): string[] { - return this.roomsResult.rooms.map((room) => room.roomId); + return this.roomsResult.sections.flatMap((section) => section.rooms).map((room) => room.roomId); } /** @@ -179,7 +220,7 @@ export class RoomListViewModel * The view should call this only for visible rooms from the roomIds list. * @throws Error if room is not found in roomsMap (indicates a programming error) */ - public getRoomItemViewModel(roomId: string): RoomListItemViewModel { + public getRoomItemViewModel(roomId: string): RoomListItemViewModel | undefined { // Check if we have a view model for this room let viewModel = this.roomItemViewModels.get(roomId); @@ -191,7 +232,11 @@ export class RoomListViewModel room = this.roomsMap.get(roomId); } - if (!room) throw new Error(`Room ${roomId} not found in roomsMap`); + if (!room) { + // Race condition: the room list has changed but the view hasn't re-rendered yet. + // Return undefined so the view can skip rendering this item. + return undefined; + } // Create new view model viewModel = new RoomListItemViewModel({ @@ -206,13 +251,17 @@ export class RoomListViewModel return viewModel; } - /** - * Not implemented - this view model does not support sections. - * Flat list mode is forced so this method is never be called. - * @throw Error if called - */ - public getSectionHeaderViewModel(): never { - throw new Error("Sections are not supported in this room list"); + public getSectionHeaderViewModel(tag: string): RoomListSectionHeaderViewModel { + if (this.roomSectionHeaderViewModels.has(tag)) return this.roomSectionHeaderViewModels.get(tag)!; + + const title = TAG_TO_TITLE_MAP[tag] || tag; + const viewModel = new RoomListSectionHeaderViewModel({ + tag, + title, + onToggleExpanded: () => this.updateRoomListData(), + }); + this.roomSectionHeaderViewModels.set(tag, viewModel); + return viewModel; } /** @@ -257,7 +306,7 @@ export class RoomListViewModel if (!currentRoomId) return; const { delta, unread } = payload; - const rooms = this.roomsResult.rooms; + const rooms = this.sections.flatMap((section) => section.rooms); const filteredRooms = unread ? // Filter the rooms to only include unread ones and the active room @@ -349,58 +398,74 @@ export class RoomListViewModel return undefined; } - const index = this.roomsResult.rooms.findIndex((room) => room.roomId === roomId); + const index = this.sections.flatMap((section) => section.rooms).findIndex((room) => room.roomId === roomId); return index >= 0 ? index : undefined; } /** - * Apply sticky room logic to keep the active room at the same index position. + * Find the position of a room within the sections list. + * Returns undefined if the room is not found. + */ + private findRoomPosition(sections: Section[], roomId: string): StickyRoomPosition | undefined { + for (const section of sections) { + const idx = section.rooms.findIndex((room) => room.roomId === roomId); + if (idx !== -1) return { sectionTag: section.tag, indexInSection: idx }; + } + return undefined; + } + + /** + * Apply sticky room logic to keep the active room at the same position within its section. * When the room list updates, this prevents the selected room from jumping around in the UI. * * @param isRoomChange - Whether this update is due to a room change (not a list update) * @param roomId - The room ID to apply sticky logic for (can be null/undefined) - * @returns The modified rooms array with sticky positioning applied + * @returns The modified sections array with sticky positioning applied */ - private applyStickyRoom(isRoomChange: boolean, roomId: string | null | undefined): Room[] { - const rooms = this.roomsResult.rooms; - - if (!roomId) { - return rooms; - } - - const newIndex = rooms.findIndex((room) => room.roomId === roomId); - const oldIndex = this.lastActiveRoomIndex; + private applyStickyRoom(isRoomChange: boolean, roomId: string | null | undefined): Section[] { + const sections = this.roomsResult.sections; // When opening another room, the index should obviously change - if (isRoomChange) { - return rooms; - } + if (!roomId || isRoomChange) return sections; - // If oldIndex is undefined, then there was no active room before - // Similarly, if newIndex is -1, the active room is not in the current list - if (newIndex === -1 || oldIndex === undefined) { - return rooms; - } + // If there was no previously tracked position, nothing to stick to + const oldPosition = this.lastActiveRoomPosition; + if (!oldPosition) return sections; - // If the index hasn't changed, we have nothing to do - if (newIndex === oldIndex) { - return rooms; - } + const newPosition = this.findRoomPosition(sections, roomId); - // If the old index falls out of the bounds of the rooms array - // (usually because rooms were removed), we can no longer place - // the active room in the same old index - if (oldIndex > rooms.length - 1) { - return rooms; - } + // If the room is no longer in the list, nothing to do + if (!newPosition) return sections; - // Making the active room sticky is as simple as removing it from - // its new index and placing it in the old index - const newRooms = [...rooms]; - const [stickyRoom] = newRooms.splice(newIndex, 1); - newRooms.splice(oldIndex, 0, stickyRoom); + // If the room moved to a different section, this is an intentional structural + // change (e.g. favourited/unfavourited), so don't apply sticky logic + if (newPosition.sectionTag !== oldPosition.sectionTag) return sections; - return newRooms; + // If the index within the section hasn't changed, nothing to do + if (newPosition.indexInSection === oldPosition.indexInSection) return sections; + + // Find the target section and apply the sticky swap within it + return sections.map((section) => { + // Different section - no change + if (section.tag !== oldPosition.sectionTag) return section; + + const sectionRooms = section.rooms; + + // If the old index falls out of the bounds of the section + // (usually because rooms were removed), we can no longer place + // the active room in the same old position + if (oldPosition.indexInSection > sectionRooms.length - 1) { + return section; + } + + // Making the active room sticky is as simple as removing it from + // its new index and placing it in the old index within the section + const newRooms = [...sectionRooms]; + const [stickyRoom] = newRooms.splice(newPosition.indexInSection, 1); + newRooms.splice(oldPosition.indexInSection, 0, stickyRoom); + + return { ...section, rooms: newRooms }; + }); } private async updateRoomListData( @@ -411,28 +476,30 @@ export class RoomListViewModel // Use override if provided (e.g., during space changes), otherwise fall back to RoomViewStore const roomId = roomIdOverride ?? SdkContextClass.instance.roomViewStore.getRoomId(); - // Apply sticky room logic to keep selected room at same position - const stickyRooms = this.applyStickyRoom(isRoomChange, roomId); + // Apply sticky room logic to keep selected room at same position within its section + const stickySections = this.applyStickyRoom(isRoomChange, roomId); - // Update roomsResult with sticky rooms + // Update roomsResult with the sticky-adjusted sections this.roomsResult = { ...this.roomsResult, - rooms: stickyRooms, + sections: stickySections, }; // Rebuild roomsMap with the reordered rooms this.updateRoomsMap(this.roomsResult); - // Calculate the active room index after applying sticky logic - const activeRoomIndex = this.getActiveRoomIndex(roomId); - - // Track the current active room index for future sticky calculations - this.lastActiveRoomIndex = activeRoomIndex; + // Track the current active room position for future sticky calculations + this.lastActiveRoomPosition = roomId ? this.findRoomPosition(this.roomsResult.sections, roomId) : undefined; // Build the complete state atomically to ensure consistency - // roomIds and roomListState must always be in sync - const roomIds = this.roomIds; - const sections = [{ id: "all", roomIds }]; + const { sections, isFlatList } = computeSections( + this.roomsResult, + (tag) => this.roomSectionHeaderViewModels.get(tag)?.isExpanded ?? true, + ); + this.sections = sections; + + // Calculate the active room index from the computed sections (which exclude collapsed sections' rooms) + const activeRoomIndex = this.getActiveRoomIndex(roomId); // Update filter keys - only update if they have actually changed to prevent unnecessary re-renders of the room list const previousFilterKeys = this.snapshot.current.roomListState.filterKeys; @@ -444,16 +511,20 @@ export class RoomListViewModel }; const activeFilterId = this.activeFilter !== undefined ? filterKeyToIdMap.get(this.activeFilter) : undefined; - const isRoomListEmpty = roomIds.length === 0; + const isRoomListEmpty = this.roomsResult.sections.every((section) => section.rooms.length === 0); const isLoadingRooms = RoomListStoreV3.instance.isLoadingRooms; + const viewSections = toRoomListSection(this.sections); + const previousSections = this.snapshot.current.sections; + // Single atomic snapshot update this.snapshot.merge({ isLoadingRooms, isRoomListEmpty, activeFilterId, roomListState: keepIfSame(this.snapshot.current.roomListState, roomListState), - sections: keepIfSame(this.snapshot.current.sections, sections), + sections: keepIfSame(previousSections, viewSections), + isFlatList, }); } @@ -475,3 +546,36 @@ export class RoomListViewModel } }; } + +/** + * Compute the sections to display in the room list based on the rooms result and section expansion state. + * @param roomsResult - The current rooms result containing sections and rooms + * @param isSectionExpanded - A function that takes a section tag and returns whether that section is currently expanded + * @returns An object containing the computed sections (with rooms removed for collapsed sections) and a boolean indicating if this is a flat list (only one section with all rooms) + */ +function computeSections( + roomsResult: RoomsResult, + isSectionExpanded: (tag: string) => boolean, +): { sections: Section[]; isFlatList: boolean } { + const sections = roomsResult.sections + // Only include sections that have rooms + .filter((section) => section.rooms.length > 0) + // Remove roomIds for sections that are currently collapsed according to their section header view model + .map((section) => ({ + ...section, + rooms: isSectionExpanded(section.tag) ? section.rooms : [], + })); + const isFlatList = sections.length === 1 && sections[0].tag === CHATS_TAG; + + return { sections, isFlatList }; +} + +/** + * Convert from the internal Section type used in the view model to the RoomListSection type used in the snapshot. + */ +function toRoomListSection(sections: Section[]): RoomListSection[] { + return sections.map(({ tag, rooms }) => ({ + id: tag, + roomIds: rooms.map((room) => room.roomId), + })); +} diff --git a/apps/web/src/viewmodels/room/EventTileActionBarViewModel.ts b/apps/web/src/viewmodels/room/EventTileActionBarViewModel.ts new file mode 100644 index 0000000000..ab6dacde49 --- /dev/null +++ b/apps/web/src/viewmodels/room/EventTileActionBarViewModel.ts @@ -0,0 +1,504 @@ +/* + * 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 { + EventStatus, + EventTimeline, + EventType, + MatrixEventEvent, + M_BEACON_INFO, + MsgType, + RelationType, + RoomStateEvent, + type MatrixEvent, +} from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; +import { + ActionBarAction, + BaseViewModel, + type ActionBarViewActions, + type ActionBarViewSnapshot, +} from "@element-hq/web-shared-components"; + +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import { Action } from "../../dispatcher/actions"; +import { type ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload"; +import { type GetRelationsForEvent } from "../../components/views/rooms/EventTile"; +import { canCancel, canEditContent, editEvent, isContentActionable } from "../../utils/EventUtils"; +import { TimelineRenderingType } from "../../contexts/RoomContext"; +import Resend from "../../Resend"; +import PinningUtils from "../../utils/PinningUtils"; +import PosthogTrackers from "../../PosthogTrackers"; +import { shouldDisplayReply } from "../../utils/Reply"; +import { MediaEventHelper } from "../../utils/MediaEventHelper"; +import SettingsStore from "../../settings/SettingsStore"; +import { type SettingKey } from "../../settings/Settings"; +import { getMediaVisibility, setMediaVisibility } from "../../utils/media/mediaVisibility"; +import { FileDownloader } from "../../utils/FileDownloader"; +import { _t } from "../../languageHandler"; +import Modal from "../../Modal"; +import ErrorDialog from "../../components/views/dialogs/ErrorDialog"; +import { ModuleApi } from "../../modules/Api"; + +/** Props for the event-tile action bar view model. */ +export interface EventTileActionBarViewModelProps { + /** The event whose available actions are being resolved. */ + mxEvent: MatrixEvent; + /** The timeline context the event is rendered within. */ + timelineRenderingType: TimelineRenderingType; + /** Whether the current user can send message-based actions such as reply. */ + canSendMessages: boolean; + /** Whether the current user can react to the event. */ + canReact: boolean; + /** Whether the tile is being rendered in search results. */ + isSearch?: boolean; + /** Whether the tile is being rendered inside a card-style surface. */ + isCard?: boolean; + /** Whether the quoted reply chain is currently expanded. */ + isQuoteExpanded?: boolean; + /** Called when the overflow options action is activated. */ + onOptionsClick?: (anchor: HTMLElement | null) => void; + /** Called when the reactions action is activated. */ + onReactionsClick?: (anchor: HTMLElement | null) => void; + /** Provides relations needed for editing when available. */ + getRelationsForEvent?: GetRelationsForEvent; + /** Called when the expand or collapse thread action is activated. */ + onToggleThreadExpanded?: (anchor: HTMLElement | null) => void; +} + +interface LocalActionBarState { + canDownload: boolean; + isDownloadLoading: boolean; +} + +interface DerivedEventState { + showCancel: boolean; + showEdit: boolean; + showPinOrUnpin: boolean; + showReact: boolean; + showReply: boolean; + showExpandCollapse: boolean; + showReplyInThread: boolean; + showThreadForDeletedMessage: boolean; + isFailed: boolean; + isPinned: boolean; + isQuoteExpanded: boolean; + isThreadReplyAllowed: boolean; +} + +interface DerivedMediaState { + showHide: boolean; + showDownload: boolean; + isDownloadEncrypted: boolean; + isDownloadLoading: boolean; +} + +/** View model for the timeline event action bar shown on event tiles. */ +export class EventTileActionBarViewModel + extends BaseViewModel + implements ActionBarViewActions +{ + private listenerCleanups: Array<() => void> = []; + private downloadPermissionRequestId = 0; + private downloadRequestId = 0; + private canDownload = true; + private isDownloadLoading = false; + private readonly downloader = new FileDownloader(); + private downloadedBlob?: Blob; + + public constructor(props: EventTileActionBarViewModelProps) { + super( + props, + EventTileActionBarViewModel.buildSnapshot(props, { + canDownload: true, + isDownloadLoading: false, + }), + ); + this.setupListeners(); + } + + private static buildSnapshot( + props: EventTileActionBarViewModelProps, + localState: LocalActionBarState, + ): ActionBarViewSnapshot { + const client = MatrixClientPeg.safeGet(); + const eventState = EventTileActionBarViewModel.getDerivedEventState(props, client); + const mediaState = EventTileActionBarViewModel.getDerivedMediaState(props.mxEvent, client, localState); + + return { + actions: EventTileActionBarViewModel.resolveActions(eventState, mediaState), + presentation: "icon", + isDownloadEncrypted: mediaState.isDownloadEncrypted, + isDownloadLoading: mediaState.isDownloadLoading, + isPinned: eventState.isPinned, + isQuoteExpanded: eventState.isQuoteExpanded, + isThreadReplyAllowed: eventState.isThreadReplyAllowed, + }; + } + + private static resolveActions(eventState: DerivedEventState, mediaState: DerivedMediaState): ActionBarAction[] { + const actions: ActionBarAction[] = []; + + if (eventState.showCancel && eventState.isFailed) { + return [ActionBarAction.Resend, ActionBarAction.Cancel]; + } + + if (mediaState.showHide) { + actions.push(ActionBarAction.Hide); + } + if (mediaState.showDownload) { + actions.push(ActionBarAction.Download); + } + if (eventState.showReact) { + actions.push(ActionBarAction.React); + } + if (!eventState.showReply && eventState.showThreadForDeletedMessage) { + actions.push(ActionBarAction.ReplyInThread); + } + if (eventState.showReply) { + actions.push(ActionBarAction.Reply); + } + if (eventState.showReply && eventState.showReplyInThread) { + actions.push(ActionBarAction.ReplyInThread); + } + if (eventState.showEdit) { + actions.push(ActionBarAction.Edit); + } + if (eventState.showPinOrUnpin) { + actions.push(ActionBarAction.Pin); + } + if (eventState.showCancel) { + actions.push(ActionBarAction.Cancel); + } + if (eventState.showExpandCollapse) { + actions.push(ActionBarAction.Expand); + } + + actions.push(ActionBarAction.Options); + + return actions; + } + + private static getDerivedEventState( + props: EventTileActionBarViewModelProps, + client: ReturnType, + ): DerivedEventState { + const { mxEvent } = props; + const contentActionable = isContentActionable(mxEvent); + const editStatus = mxEvent.replacingEvent()?.status; + const redactStatus = mxEvent.localRedactionEvent()?.status; + const relationType = mxEvent.getRelation()?.rel_type; + + return { + showCancel: canCancel(mxEvent.status) || canCancel(editStatus) || canCancel(redactStatus), + showEdit: canEditContent(client, mxEvent), + showPinOrUnpin: PinningUtils.canPin(client, mxEvent) || PinningUtils.canUnpin(client, mxEvent), + showReact: contentActionable && props.canReact && !props.isSearch, + showReply: contentActionable && props.canSendMessages, + isThreadReplyAllowed: !(!!relationType && relationType !== RelationType.Thread), + showExpandCollapse: props.isQuoteExpanded !== undefined && shouldDisplayReply(mxEvent), + showReplyInThread: contentActionable && EventTileActionBarViewModel.canShowReplyInThreadAction(props), + showThreadForDeletedMessage: + !contentActionable && + props.timelineRenderingType === TimelineRenderingType.Room && + Boolean(mxEvent.getThread()), + isFailed: [mxEvent.status, editStatus, redactStatus].includes(EventStatus.NOT_SENT), + isPinned: PinningUtils.isPinned(client, mxEvent), + isQuoteExpanded: props.isQuoteExpanded ?? false, + }; + } + + private static getDerivedMediaState( + mxEvent: MatrixEvent, + client: ReturnType, + localState: LocalActionBarState, + ): DerivedMediaState { + const contentActionable = isContentActionable(mxEvent); + const mediaHelper = MediaEventHelper.isEligible(mxEvent) ? new MediaEventHelper(mxEvent) : undefined; + + return { + showDownload: contentActionable && Boolean(mediaHelper) && localState.canDownload, + showHide: contentActionable && MediaEventHelper.canHide(mxEvent) && getMediaVisibility(mxEvent, client), + isDownloadEncrypted: mediaHelper?.media.isEncrypted ?? false, + isDownloadLoading: localState.isDownloadLoading, + }; + } + + private computeSnapshot(): ActionBarViewSnapshot { + return EventTileActionBarViewModel.buildSnapshot(this.props, { + canDownload: this.canDownload, + isDownloadLoading: this.isDownloadLoading, + }); + } + + private static canShowReplyInThreadAction(props: EventTileActionBarViewModelProps): boolean { + const inNotThreadTimeline = props.timelineRenderingType !== TimelineRenderingType.Thread; + const content = props.mxEvent.getContent(); + const isAllowedMessageType = + ![MsgType.KeyVerificationRequest].includes(content.msgtype as MsgType) && + !M_BEACON_INFO.matches(props.mxEvent.getType()); + + return inNotThreadTimeline && isAllowedMessageType; + } + + private setupListeners(): void { + this.teardownListeners(); + + const { mxEvent } = this.props; + const roomId = mxEvent.getRoomId(); + this.trackEvent(mxEvent, MatrixEventEvent.Status, this.refreshSnapshot); + this.trackEvent(mxEvent, MatrixEventEvent.Decrypted, this.refreshSnapshot); + this.trackEvent(mxEvent, MatrixEventEvent.BeforeRedaction, this.refreshSnapshot); + this.watchSetting("mediaPreviewConfig", roomId ?? null); + this.watchSetting("showMediaEventIds", null); + + const roomState = roomId + ? MatrixClientPeg.safeGet().getRoom(roomId)?.getLiveTimeline().getState(EventTimeline.FORWARDS) + : undefined; + if (roomState) { + roomState.on(RoomStateEvent.Events, this.onRoomEvent); + this.addListenerCleanup(() => roomState.off(RoomStateEvent.Events, this.onRoomEvent)); + } + + MatrixClientPeg.safeGet().decryptEventIfNeeded(mxEvent); + void this.updateDownloadPermission(++this.downloadPermissionRequestId); + } + + private teardownListeners(): void { + for (const cleanup of this.listenerCleanups) { + cleanup(); + } + this.listenerCleanups = []; + } + + private addListenerCleanup(cleanup: () => void): void { + this.listenerCleanups.push(cleanup); + } + + private trackEvent(event: MatrixEvent, eventName: MatrixEventEvent, callback: (...args: unknown[]) => void): void { + event.on(eventName, callback); + this.addListenerCleanup(() => event.off(eventName, callback)); + } + + private watchSetting(settingName: SettingKey, roomId: string | null): void { + const watcherRef = SettingsStore.watchSetting(settingName, roomId, this.refreshSnapshot); + this.addListenerCleanup(() => SettingsStore.unwatchSetting(watcherRef)); + } + + private readonly refreshSnapshot = (): void => { + this.snapshot.merge(this.computeSnapshot()); + }; + + private resetEventState(): void { + this.downloadedBlob = undefined; + this.canDownload = true; + this.isDownloadLoading = false; + } + + private isCurrentDownloadPermissionRequest(requestId: number, mxEvent: MatrixEvent): boolean { + return !this.isDisposed && requestId === this.downloadPermissionRequestId && this.props.mxEvent === mxEvent; + } + + private updateDownloadPermissionState(requestId: number, mxEvent: MatrixEvent, canDownload: boolean): boolean { + if (!this.isCurrentDownloadPermissionRequest(requestId, mxEvent)) return false; + this.canDownload = canDownload; + this.refreshSnapshot(); + return true; + } + + private async updateDownloadPermission(requestId: number): Promise { + const { mxEvent } = this.props; + const hints = ModuleApi.instance.customComponents.getHintsForMessage(mxEvent); + + if (!hints?.allowDownloadingMedia) { + this.updateDownloadPermissionState(requestId, mxEvent, true); + return; + } + + if (!this.updateDownloadPermissionState(requestId, mxEvent, false)) return; + + try { + const canDownload = await hints.allowDownloadingMedia(); + this.updateDownloadPermissionState(requestId, mxEvent, canDownload); + } catch (err) { + logger.error(`Failed to check media download permission for ${mxEvent.getId()}`, err); + this.updateDownloadPermissionState(requestId, mxEvent, false); + } + } + + private isCurrentDownloadRequest(requestId: number, mxEvent: MatrixEvent): boolean { + return !this.isDisposed && requestId === this.downloadRequestId && this.props.mxEvent === mxEvent; + } + + private setDownloadLoading(requestId: number, mxEvent: MatrixEvent, isDownloadLoading: boolean): boolean { + if (!this.isCurrentDownloadRequest(requestId, mxEvent)) return false; + this.isDownloadLoading = isDownloadLoading; + this.refreshSnapshot(); + return true; + } + + private readonly onRoomEvent = (event?: MatrixEvent): void => { + if (!event) return; + if (event.getType() !== EventType.RoomPinnedEvents && event.getType() !== EventType.RoomJoinRules) return; + this.refreshSnapshot(); + }; + + /** + * Runs an action against the failed event variant that is still actionable. + */ + private runActionOnFailedEv(fn: (ev: MatrixEvent) => void, checkFn?: (ev: MatrixEvent) => boolean): void { + const shouldUseEvent = checkFn ?? (() => true); + const { mxEvent } = this.props; + const tryOrder = [mxEvent.localRedactionEvent(), mxEvent.replacingEvent(), mxEvent]; + + for (const event of tryOrder) { + if (event && shouldUseEvent(event)) { + fn(event); + break; + } + } + } + + /** Updates props, refreshes listeners when the event changes, and rebuilds the snapshot. */ + public setProps(newProps: Partial): void { + const prevEvent = this.props.mxEvent; + const prevRoomId = prevEvent.getRoomId(); + + this.props = { + ...this.props, + ...newProps, + }; + + if (this.props.mxEvent !== prevEvent || this.props.mxEvent.getRoomId() !== prevRoomId) { + this.resetEventState(); + this.setupListeners(); + } + + this.refreshSnapshot(); + } + + /** Removes listeners and releases resources owned by the view model. */ + public override dispose(): void { + this.teardownListeners(); + super.dispose(); + } + + /** Starts a reply to the current event. */ + public onReplyClick = (_anchor: HTMLElement | null): void => { + defaultDispatcher.dispatch({ + action: "reply_to_event", + event: this.props.mxEvent, + context: this.props.timelineRenderingType, + }); + }; + + /** Opens the edit composer for the current event. */ + public onEditClick = (_anchor: HTMLElement | null): void => { + editEvent( + MatrixClientPeg.safeGet(), + this.props.mxEvent, + this.props.timelineRenderingType, + this.props.getRelationsForEvent, + ); + }; + + /** Retries sending the failed event variant that is still actionable. */ + public onResendClick = (_anchor: HTMLElement | null): void => { + this.runActionOnFailedEv((event) => Resend.resend(MatrixClientPeg.safeGet(), event)); + }; + + /** Cancels the failed event variant that is still cancellable. */ + public onCancelClick = (_anchor: HTMLElement | null): void => { + this.runActionOnFailedEv( + (event) => Resend.removeFromQueue(MatrixClientPeg.safeGet(), event), + (event) => canCancel(event.status), + ); + }; + + /** Pins or unpins the current event. */ + public onPinClick = async (_anchor: HTMLElement | null): Promise => { + const isPinned = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent); + await PinningUtils.pinOrUnpinEvent(MatrixClientPeg.safeGet(), this.props.mxEvent); + PosthogTrackers.trackPinUnpinMessage(isPinned ? "Pin" : "Unpin", "Timeline"); + }; + + /** Downloads the media content for the current event when available. */ + public onDownloadClick = async (_anchor: HTMLElement | null): Promise => { + if (this.isDownloadLoading || !this.canDownload) return; + const requestId = ++this.downloadRequestId; + const { mxEvent } = this.props; + + try { + if (!this.setDownloadLoading(requestId, mxEvent, true)) return; + const mediaEventHelper = new MediaEventHelper(mxEvent); + + if (!this.downloadedBlob) { + const downloadedBlob = await mediaEventHelper.sourceBlob.value; + if (!this.isCurrentDownloadRequest(requestId, mxEvent)) return; + this.downloadedBlob = downloadedBlob; + } + + await this.downloader.download({ + blob: this.downloadedBlob, + name: mediaEventHelper.fileName ?? _t("common|image"), + }); + } catch (e) { + if (!this.isCurrentDownloadRequest(requestId, mxEvent)) return; + Modal.createDialog(ErrorDialog, { + title: _t("timeline|download_failed"), + description: `${_t("timeline|download_failed_description")}\n\n${String(e)}`, + }); + } finally { + this.setDownloadLoading(requestId, mxEvent, false); + } + }; + + /** Hides the media preview for the current event. */ + public onHideClick = (_anchor: HTMLElement | null): void => { + void setMediaVisibility(this.props.mxEvent, false); + }; + + /** Forwards the expand or collapse thread action using the triggering button as the anchor. */ + public onToggleThreadExpanded = (anchor: HTMLElement | null): void => { + this.props.onToggleThreadExpanded?.(anchor); + }; + + /** Forwards the overflow options action using the triggering button as the anchor. */ + public onOptionsClick = (anchor: HTMLElement | null): void => { + this.props.onOptionsClick?.(anchor); + }; + + /** Forwards the reactions action using the triggering button as the anchor. */ + public onReactionsClick = (anchor: HTMLElement | null): void => { + this.props.onReactionsClick?.(anchor); + }; + + /** Opens or starts the thread associated with the current event. */ + public onReplyInThreadClick = (_anchor: HTMLElement | null): void => { + const { mxEvent, isCard } = this.props; + const thread = mxEvent.getThread(); + + if (thread?.rootEvent && !mxEvent.isThreadRoot) { + defaultDispatcher.dispatch({ + action: Action.ShowThread, + rootEvent: thread.rootEvent, + initialEvent: mxEvent, + scroll_into_view: true, + highlighted: true, + push: isCard, + }); + return; + } + + defaultDispatcher.dispatch({ + action: Action.ShowThread, + rootEvent: mxEvent, + push: isCard, + }); + }; +} diff --git a/apps/web/src/viewmodels/room/ThreadListActionBarViewModel.ts b/apps/web/src/viewmodels/room/ThreadListActionBarViewModel.ts new file mode 100644 index 0000000000..60cbaf3c27 --- /dev/null +++ b/apps/web/src/viewmodels/room/ThreadListActionBarViewModel.ts @@ -0,0 +1,57 @@ +/* + * 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 { + BaseViewModel, + ActionBarAction, + type ActionBarViewActions, + type ActionBarViewSnapshot, +} from "@element-hq/web-shared-components"; + +/** Props for the thread-list action bar view model. */ +export interface ThreadListActionBarViewModelProps { + /** Called when the view in room action is activated. */ + onViewInRoomClick?: (anchor: HTMLElement | null) => void; + /** Called when the copy link action is activated. */ + onCopyLinkClick?: (anchor: HTMLElement | null) => void; +} + +/** View model for the icon-only action bar shown in the thread list. */ +export class ThreadListActionBarViewModel + extends BaseViewModel + implements ActionBarViewActions +{ + public constructor(props: ThreadListActionBarViewModelProps) { + super(props, { + actions: [ActionBarAction.ViewInRoom, ActionBarAction.CopyLink], + presentation: "icon", + isDownloadEncrypted: false, + isDownloadLoading: false, + isPinned: false, + isQuoteExpanded: false, + isThreadReplyAllowed: true, + }); + } + + /** Updates the action handlers exposed by the view model. */ + public setProps(newProps: Partial): void { + this.props = { + ...this.props, + ...newProps, + }; + } + + /** Forwards the view in room action using the triggering button as the anchor. */ + public onViewInRoomClick = (anchor: HTMLElement | null): void => { + this.props.onViewInRoomClick?.(anchor); + }; + + /** Forwards the copy link action using the triggering button as the anchor. */ + public onCopyLinkClick = (anchor: HTMLElement | null): void => { + this.props.onCopyLinkClick?.(anchor); + }; +} diff --git a/apps/web/test/unit-tests/components/structures/RoomView-test.tsx b/apps/web/test/unit-tests/components/structures/RoomView-test.tsx index a1b43be232..77bbdd8d47 100644 --- a/apps/web/test/unit-tests/components/structures/RoomView-test.tsx +++ b/apps/web/test/unit-tests/components/structures/RoomView-test.tsx @@ -948,7 +948,10 @@ describe("RoomView", () => { expect(container.querySelector(".mx_RoomView_searchResultsPanel")).toBeVisible(); }); - await userEvent.hover(getByText("search term")); + const searchResultTile = getByText("search term").closest(".mx_EventTile"); + expect(searchResultTile).not.toBeNull(); + + await userEvent.hover(searchResultTile!); await userEvent.click(await findByLabelText("Edit")); await waitFor(() => { @@ -1014,7 +1017,10 @@ describe("RoomView", () => { }); const prom = untilDispatch(Action.ViewRoom, defaultDispatcher); - await userEvent.hover(getByText("search term")); + const searchResultTile = getByText("search term").closest(".mx_EventTile"); + expect(searchResultTile).not.toBeNull(); + + await userEvent.hover(searchResultTile!); await userEvent.click(await findByLabelText("Edit")); await expect(prom).resolves.toEqual(expect.objectContaining({ room_id: room2.roomId })); diff --git a/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap index 048caf3d52..1ced7286ed 100644 --- a/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap @@ -86,15 +86,23 @@ exports[` should match the snapshot 1`] = `
@@ -224,15 +232,23 @@ exports[` should support events with 1`] = ` @@ -278,15 +294,23 @@ exports[` should support events with 1`] = ` @@ -314,15 +338,23 @@ exports[` should support events with 1`] = ` @@ -332,7 +364,7 @@ exports[` should support events with 1`] = `
({ - decryptAttachment: jest.fn().mockResolvedValue(new Blob(["TESTFILE"], { type: "application/octet-stream" })), -})); - -describe("DownloadActionButton", () => { - const plainEvent = new MatrixEvent({ - room_id: "!room:id", - sender: "@user:id", - type: "m.room.message", - content: { - body: "test", - msgtype: "m.image", - url: "mxc://matrix.org/1234", - }, - }); - - beforeEach(() => { - jest.restoreAllMocks(); - }); - - afterEach(() => { - clearAllModals(); - }); - - it("should show error if media API returns one", async () => { - const cli = stubClient(); - // eslint-disable-next-line no-restricted-properties - mocked(cli.mxcUrlToHttp).mockImplementation( - (mxc) => `https://matrix.org/_matrix/media/r0/download/${mxc.slice(6)}`, - ); - - fetchMock.getOnce("https://matrix.org/_matrix/media/r0/download/matrix.org/1234", { - status: 404, - body: { errcode: "M_NOT_FOUND", error: "Not found" }, - }); - - const mediaEventHelper = new MediaEventHelper(plainEvent); - - render( mediaEventHelper} />); - - const spy = jest.spyOn(Modal, "createDialog"); - - fireEvent.click(screen.getByRole("button")); - await waitFor(() => - expect(spy).toHaveBeenCalledWith( - ErrorDialog, - expect.objectContaining({ - title: "Download failed", - }), - ), - ); - }); - - it("should show download tooltip on hover", async () => { - stubClient(); - - const user = userEvent.setup(); - - fetchMock.getOnce("https://matrix.org/_matrix/media/r0/download/matrix.org/1234", "TESTFILE"); - - const event = new MatrixEvent({ - room_id: "!room:id", - sender: "@user:id", - type: "m.room.message", - content: { - body: "test", - msgtype: "m.image", - url: "mxc://matrix.org/1234", - }, - }); - - render( undefined} />); - - const button = screen.getByRole("button"); - await user.hover(button); - - await waitFor(() => { - expect(screen.getByRole("tooltip")).toHaveTextContent("Download"); - }); - }); - - it("should show downloading tooltip while unencrypted files are downloading", async () => { - const user = userEvent.setup(); - - stubClient(); - - fetchMock.getOnce("http://this.is.a.url/matrix.org/1234", "TESTFILE"); - - const mediaEventHelper = new MediaEventHelper(plainEvent); - - render( mediaEventHelper} />); - - const button = screen.getByRole("button"); - await user.hover(button); - - await user.click(button); - - await waitFor(() => { - expect(screen.getByRole("tooltip")).toHaveTextContent("Downloading"); - }); - }); - - it("should show decrypting tooltip while encrypted files are downloading", async () => { - const user = userEvent.setup(); - - stubClient(); - - fetchMock.getOnce("http://this.is.a.url/matrix.org/1234", "UFTUGJMF"); - - const e2eEvent = new MatrixEvent({ - room_id: "!room:id", - sender: "@user:id", - type: "m.room.message", - content: { - body: "test", - msgtype: "m.image", - file: { url: "mxc://matrix.org/1234" }, - }, - }); - - const mediaEventHelper = new MediaEventHelper(e2eEvent); - - render( mediaEventHelper} />); - - const button = screen.getByRole("button"); - await user.hover(button); - - await user.click(button); - - await waitFor(() => { - expect(screen.getByRole("tooltip")).toHaveTextContent("Decrypting"); - }); - }); -}); diff --git a/apps/web/test/unit-tests/components/views/messages/HideActionButton-test.tsx b/apps/web/test/unit-tests/components/views/messages/HideActionButton-test.tsx deleted file mode 100644 index fbb8ab7dd0..0000000000 --- a/apps/web/test/unit-tests/components/views/messages/HideActionButton-test.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/* -Copyright 2024,2025 New Vector Ltd. -Copyright 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import React from "react"; -import { fireEvent, render, screen } from "jest-matrix-react"; -import { MatrixEvent, type MatrixClient } from "matrix-js-sdk/src/matrix"; - -import { HideActionButton } from "../../../../../src/components/views/messages/HideActionButton"; -import SettingsStore from "../../../../../src/settings/SettingsStore"; -import { SettingLevel } from "../../../../../src/settings/SettingLevel"; -import type { Settings } from "../../../../../src/settings/Settings"; -import { MediaPreviewValue } from "../../../../../src/@types/media_preview"; -import { getMockClientWithEventEmitter, withClientContextRenderOptions } from "../../../../test-utils"; -import type { MockedObject } from "jest-mock"; - -function mockSetting(mediaPreviews: MediaPreviewValue, showMediaEventIds: Settings["showMediaEventIds"]["default"]) { - jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => { - if (settingName === "mediaPreviewConfig") { - return { media_previews: mediaPreviews, invite_avatars: MediaPreviewValue.Off }; - } else if (settingName === "showMediaEventIds") { - return showMediaEventIds; - } - throw Error(`Unexpected setting ${settingName}`); - }); -} - -const EVENT_ID = "$foo:bar"; - -const event = new MatrixEvent({ - event_id: EVENT_ID, - room_id: "!room:id", - sender: "@user:id", - type: "m.room.message", - content: { - body: "test", - msgtype: "m.image", - url: "mxc://matrix.org/1234", - }, -}); - -describe("HideActionButton", () => { - let cli: MockedObject; - beforeEach(() => { - cli = getMockClientWithEventEmitter({ - getRoom: jest.fn(), - getUserId: jest.fn(), - }); - }); - afterEach(() => { - jest.restoreAllMocks(); - }); - it("should show button when event is visible by showMediaEventIds setting", async () => { - mockSetting(MediaPreviewValue.Off, { [EVENT_ID]: true }); - render(, withClientContextRenderOptions(cli)); - expect(screen.getByRole("button")).toBeVisible(); - }); - it("should show button when event is visible by mediaPreviewConfig setting", async () => { - mockSetting(MediaPreviewValue.On, {}); - render(, withClientContextRenderOptions(cli)); - expect(screen.getByRole("button")).toBeVisible(); - }); - it("should hide button when event is hidden by showMediaEventIds setting", async () => { - mockSetting(MediaPreviewValue.Off, { [EVENT_ID]: false }); - render(, withClientContextRenderOptions(cli)); - expect(screen.queryByRole("button")).toBeNull(); - }); - it("should hide button when event is hidden by showImages setting", async () => { - mockSetting(MediaPreviewValue.Off, {}); - render(, withClientContextRenderOptions(cli)); - expect(screen.queryByRole("button")).toBeNull(); - }); - it("should store event as hidden when clicked", async () => { - const spy = jest.spyOn(SettingsStore, "setValue"); - render(, withClientContextRenderOptions(cli)); - fireEvent.click(screen.getByRole("button")); - expect(spy).toHaveBeenCalledWith("showMediaEventIds", null, SettingLevel.DEVICE, { "$foo:bar": false }); - // Button should be hidden after the setting is set. - expect(screen.queryByRole("button")).toBeNull(); - }); -}); diff --git a/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx b/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx index e20c8791e8..4a25bcb57f 100644 --- a/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx +++ b/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx @@ -19,7 +19,11 @@ import { } from "../../../../test-utils"; import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper"; import SettingsStore from "../../../../../src/settings/SettingsStore"; -import { FileBodyFactory, renderMBody } from "../../../../../src/components/views/messages/MBodyFactory"; +import { + FileBodyFactory, + VideoBodyFactory, + renderMBody, +} from "../../../../../src/components/views/messages/MBodyFactory"; import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext.ts"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; @@ -90,10 +94,14 @@ describe("MBodyFactory", () => { expect(container).toMatchSnapshot(); }); - it.each(["m.audio", "m.video", "m.text"])("returns null for unsupported msgtype %s", (msgtype) => { + it.each(["m.audio", "m.text"])("returns null for unsupported msgtype %s", (msgtype) => { expect(renderMBody({ ...props, mxEvent: mkEvent(msgtype) })).toBeNull(); }); + it("returns the video body factory for m.video", () => { + expect(renderMBody({ ...props, mxEvent: mkEvent("m.video") })?.type).toBe(VideoBodyFactory); + }); + it("returns null when msgtype is missing", () => { expect(renderMBody({ ...props, mxEvent: mkEvent() })).toBeNull(); }); @@ -116,7 +124,7 @@ describe("MBodyFactory", () => { }); }); - it.each(["m.file", "m.audio", "m.video"])( + it.each(["m.file", "m.audio"])( "renderMBody fallback shows %s generic placeholder when showFileInfo is true", async (msgtype) => { const mediaEvent = new MatrixEvent({ diff --git a/apps/web/test/unit-tests/components/views/messages/MVideoBody-test.tsx b/apps/web/test/unit-tests/components/views/messages/MVideoBody-test.tsx index 51390f2136..9348a5b0b7 100644 --- a/apps/web/test/unit-tests/components/views/messages/MVideoBody-test.tsx +++ b/apps/web/test/unit-tests/components/views/messages/MVideoBody-test.tsx @@ -23,7 +23,7 @@ import { mockClientMethodsUser, withClientContextRenderOptions, } from "../../../../test-utils"; -import MVideoBody from "../../../../../src/components/views/messages/MVideoBody"; +import { VideoBodyFactory } from "../../../../../src/components/views/messages/MBodyFactory"; import type { IBodyProps } from "../../../../../src/components/views/messages/IBodyProps"; import SettingsStore from "../../../../../src/settings/SettingsStore"; import { MediaPreviewValue } from "../../../../../src/@types/media_preview"; @@ -33,7 +33,7 @@ jest.mock("matrix-encrypt-attachment", () => ({ decryptAttachment: jest.fn(), })); -describe("MVideoBody", () => { +describe("VideoBodyFactory", () => { const ourUserId = "@user:server"; const senderUserId = "@other_use:server"; const deviceId = "DEADB33F"; @@ -122,23 +122,25 @@ describe("MVideoBody", () => { mediaEventHelper: { media: { isEncrypted: false } } as MediaEventHelper, }; - const { asFragment } = render( + const { container } = render( - + , withClientContextRenderOptions(cli), ); - expect(asFragment()).toMatchSnapshot(); - // If we get here, we did not crash. + expect(container.querySelector("video")).not.toBeNull(); }); it("should show poster for encrypted media before downloading it", async () => { fetchMock.getOnce(thumbUrl, { status: 200 }); - const { asFragment } = render( - , + render( + , withClientContextRenderOptions(cli), ); - expect(asFragment()).toMatchSnapshot(); + expect(await screen.findByLabelText("alt for a test video")).toHaveAttribute("poster"); }); describe("with video previews/thumbnails disabled", () => { @@ -161,7 +163,7 @@ describe("MVideoBody", () => { fetchMock.getOnce(thumbUrl, { status: 200 }); render( - , @@ -177,7 +179,7 @@ describe("MVideoBody", () => { fetchMock.getOnce(thumbUrl, { status: 200 }); render( - , @@ -189,6 +191,7 @@ describe("MVideoBody", () => { expect(placeholderButton).toBeInTheDocument(); fireEvent.click(placeholderButton); + await screen.findByLabelText("alt for a test video"); expect(fetchMock).toHaveFetched(thumbUrl); }); @@ -214,16 +217,16 @@ describe("MVideoBody", () => { }, }, }); - const { asFragment } = render( - , withClientContextRenderOptions(cli), ); + expect(await screen.findByLabelText("alt for a test video")).toBeInTheDocument(); expect(fetchMock).toHaveFetched(thumbUrl); - expect(asFragment()).toMatchSnapshot(); }); }); }); diff --git a/apps/web/test/unit-tests/components/views/messages/MessageActionBar-test.tsx b/apps/web/test/unit-tests/components/views/messages/MessageActionBar-test.tsx deleted file mode 100644 index e44e25d78d..0000000000 --- a/apps/web/test/unit-tests/components/views/messages/MessageActionBar-test.tsx +++ /dev/null @@ -1,564 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022, 2023 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import React from "react"; -import { act, render, fireEvent, screen, waitFor } from "jest-matrix-react"; -import { - EventType, - EventStatus, - MatrixEvent, - MatrixEventEvent, - MsgType, - Room, - FeatureSupport, - Thread, - EventTimeline, - RoomStateEvent, -} from "matrix-js-sdk/src/matrix"; - -import MessageActionBar from "../../../../../src/components/views/messages/MessageActionBar"; -import { - getMockClientWithEventEmitter, - mockClientMethodsUser, - mockClientMethodsEvents, - makeBeaconInfoEvent, -} from "../../../../test-utils"; -import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; -import RoomContext, { type RoomContextType, TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; -import dispatcher from "../../../../../src/dispatcher/dispatcher"; -import SettingsStore from "../../../../../src/settings/SettingsStore"; -import { Action } from "../../../../../src/dispatcher/actions"; -import PinningUtils from "../../../../../src/utils/PinningUtils"; -import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; - -jest.mock("../../../../../src/dispatcher/dispatcher"); - -describe("", () => { - const userId = "@alice:server.org"; - const roomId = "!room:server.org"; - - const client = getMockClientWithEventEmitter({ - ...mockClientMethodsUser(userId), - ...mockClientMethodsEvents(), - getRoom: jest.fn(), - setRoomAccountData: jest.fn(), - sendStateEvent: jest.fn(), - }); - const room = new Room(roomId, client, userId); - - const alicesMessageEvent = new MatrixEvent({ - type: EventType.RoomMessage, - sender: userId, - room_id: roomId, - content: { - msgtype: MsgType.Text, - body: "Hello", - }, - event_id: "$alices_message", - }); - - const bobsMessageEvent = new MatrixEvent({ - type: EventType.RoomMessage, - sender: "@bob:server.org", - room_id: roomId, - content: { - msgtype: MsgType.Text, - body: "I am bob", - }, - event_id: "$bobs_message", - }); - - const redactedEvent = new MatrixEvent({ - type: EventType.RoomMessage, - sender: userId, - }); - redactedEvent.makeRedacted(redactedEvent, room); - - const localStorageMock = (() => { - let store: Record = {}; - return { - getItem: jest.fn().mockImplementation((key) => store[key] ?? null), - setItem: jest.fn().mockImplementation((key, value) => { - store[key] = value; - }), - clear: jest.fn().mockImplementation(() => { - store = {}; - }), - removeItem: jest.fn().mockImplementation((key) => delete store[key]), - }; - })(); - Object.defineProperty(window, "localStorage", { - value: localStorageMock, - writable: true, - }); - - jest.spyOn(room, "getPendingEvents").mockReturnValue([]); - - client.getRoom.mockReturnValue(room); - - const defaultProps = { - getTile: jest.fn(), - getReplyChain: jest.fn(), - toggleThreadExpanded: jest.fn(), - mxEvent: alicesMessageEvent, - permalinkCreator: new RoomPermalinkCreator(room), - }; - const defaultRoomContext = { - ...RoomContext, - timelineRenderingType: TimelineRenderingType.Room, - canSendMessages: true, - canReact: true, - room, - } as unknown as RoomContextType; - const getComponent = (props = {}, roomContext: Partial = {}) => - render( - - - , - ); - - beforeEach(() => { - jest.clearAllMocks(); - // The base case is that we have received the remote echo and have an eventId. No sending status. - alicesMessageEvent.setStatus(null); - jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); - jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined); - }); - - afterAll(() => { - jest.spyOn(SettingsStore, "getValue").mockRestore(); - jest.spyOn(SettingsStore, "setValue").mockRestore(); - }); - - it("kills event listeners on unmount", () => { - const offSpy = jest.spyOn(alicesMessageEvent, "off").mockClear(); - const wrapper = getComponent({ mxEvent: alicesMessageEvent }); - - act(() => { - wrapper.unmount(); - }); - - expect(offSpy.mock.calls[0][0]).toEqual(MatrixEventEvent.Status); - expect(offSpy.mock.calls[1][0]).toEqual(MatrixEventEvent.Decrypted); - expect(offSpy.mock.calls[2][0]).toEqual(MatrixEventEvent.BeforeRedaction); - - expect(client.decryptEventIfNeeded).toHaveBeenCalled(); - }); - - describe("decryption", () => { - it("decrypts event if needed", () => { - getComponent({ mxEvent: alicesMessageEvent }); - expect(client.decryptEventIfNeeded).toHaveBeenCalled(); - }); - - it("updates component on decrypted event", () => { - const decryptingEvent = new MatrixEvent({ - type: EventType.RoomMessageEncrypted, - sender: userId, - room_id: roomId, - content: {}, - }); - jest.spyOn(decryptingEvent, "isBeingDecrypted").mockReturnValue(true); - const { queryByLabelText } = getComponent({ mxEvent: decryptingEvent }); - - // still encrypted event is not actionable => no reply button - expect(queryByLabelText("Reply")).toBeFalsy(); - - act(() => { - // ''decrypt'' the event - decryptingEvent.event.type = alicesMessageEvent.getType(); - decryptingEvent.event.content = alicesMessageEvent.getContent(); - decryptingEvent.emit(MatrixEventEvent.Decrypted, decryptingEvent); - }); - - // new available actions after decryption - expect(queryByLabelText("Reply")).toBeTruthy(); - }); - }); - - describe("status", () => { - it("updates component when event status changes", () => { - alicesMessageEvent.setStatus(EventStatus.QUEUED); - const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); - - // pending event status, cancel action available - expect(queryByLabelText("Delete")).toBeTruthy(); - - act(() => { - alicesMessageEvent.setStatus(EventStatus.SENT); - }); - - // event is sent, no longer cancelable - expect(queryByLabelText("Delete")).toBeFalsy(); - }); - }); - - describe("redaction", () => { - // this doesn't do what it's supposed to - // because beforeRedaction event is fired... before redaction - // event is unchanged at point when this component updates - // TODO file bug - it.skip("updates component on before redaction event", () => { - const event = new MatrixEvent({ - type: EventType.RoomMessage, - sender: userId, - room_id: roomId, - content: { - msgtype: MsgType.Text, - body: "Hello", - }, - }); - const { queryByLabelText } = getComponent({ mxEvent: event }); - - // no pending redaction => no delete button - expect(queryByLabelText("Delete")).toBeFalsy(); - - act(() => { - const redactionEvent = new MatrixEvent({ - type: EventType.RoomRedaction, - sender: userId, - room_id: roomId, - }); - redactionEvent.setStatus(EventStatus.QUEUED); - event.markLocallyRedacted(redactionEvent); - }); - - // updated with local redaction event, delete now available - expect(queryByLabelText("Delete")).toBeTruthy(); - }); - }); - - describe("options button", () => { - it("renders options menu", () => { - const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); - expect(queryByLabelText("Options")).toBeTruthy(); - }); - - it("opens message context menu on click", () => { - const { getByTestId, queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); - fireEvent.click(queryByLabelText("Options")!); - expect(getByTestId("mx_MessageContextMenu")).toBeTruthy(); - }); - }); - - describe("reply button", () => { - it("renders reply button on own actionable event", () => { - const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); - expect(queryByLabelText("Reply")).toBeTruthy(); - }); - - it("renders reply button on others actionable event", () => { - const { queryByLabelText } = getComponent({ mxEvent: bobsMessageEvent }, { canSendMessages: true }); - expect(queryByLabelText("Reply")).toBeTruthy(); - }); - - it("does not render reply button on non-actionable event", () => { - // redacted event is not actionable - const { queryByLabelText } = getComponent({ mxEvent: redactedEvent }); - expect(queryByLabelText("Reply")).toBeFalsy(); - }); - - it("does not render reply button when user cannot send messaged", () => { - // redacted event is not actionable - const { queryByLabelText } = getComponent({ mxEvent: redactedEvent }, { canSendMessages: false }); - expect(queryByLabelText("Reply")).toBeFalsy(); - }); - - it("dispatches reply event on click", () => { - const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); - - fireEvent.click(queryByLabelText("Reply")!); - - expect(dispatcher.dispatch).toHaveBeenCalledWith({ - action: "reply_to_event", - event: alicesMessageEvent, - context: TimelineRenderingType.Room, - }); - }); - }); - - describe("react button", () => { - it("renders react button on own actionable event", () => { - const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); - expect(queryByLabelText("React")).toBeTruthy(); - }); - - it("renders react button on others actionable event", () => { - const { queryByLabelText } = getComponent({ mxEvent: bobsMessageEvent }); - expect(queryByLabelText("React")).toBeTruthy(); - }); - - it("does not render react button on non-actionable event", () => { - // redacted event is not actionable - const { queryByLabelText } = getComponent({ mxEvent: redactedEvent }); - expect(queryByLabelText("React")).toBeFalsy(); - }); - - it("does not render react button when user cannot react", () => { - // redacted event is not actionable - const { queryByLabelText } = getComponent({ mxEvent: redactedEvent }, { canReact: false }); - expect(queryByLabelText("React")).toBeFalsy(); - }); - - it("opens reaction picker on click", () => { - const { queryByLabelText, getByTestId } = getComponent({ mxEvent: alicesMessageEvent }); - fireEvent.click(queryByLabelText("React")!); - expect(getByTestId("mx_EmojiPicker")).toBeTruthy(); - }); - }); - - describe("cancel button", () => { - it("renders cancel button for an event with a cancelable status", () => { - alicesMessageEvent.setStatus(EventStatus.QUEUED); - const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); - expect(queryByLabelText("Delete")).toBeTruthy(); - }); - - it("renders cancel button for an event with a pending edit", () => { - const event = new MatrixEvent({ - type: EventType.RoomMessage, - sender: userId, - room_id: roomId, - content: { - msgtype: MsgType.Text, - body: "Hello", - }, - }); - event.setStatus(EventStatus.SENT); - const replacingEvent = new MatrixEvent({ - type: EventType.RoomMessage, - sender: userId, - room_id: roomId, - content: { - msgtype: MsgType.Text, - body: "replacing event body", - }, - }); - replacingEvent.setStatus(EventStatus.QUEUED); - event.makeReplaced(replacingEvent); - const { queryByLabelText } = getComponent({ mxEvent: event }); - expect(queryByLabelText("Delete")).toBeTruthy(); - }); - - it("renders cancel button for an event with a pending redaction", () => { - const event = new MatrixEvent({ - type: EventType.RoomMessage, - sender: userId, - room_id: roomId, - content: { - msgtype: MsgType.Text, - body: "Hello", - }, - }); - event.setStatus(EventStatus.SENT); - - const redactionEvent = new MatrixEvent({ - type: EventType.RoomRedaction, - sender: userId, - room_id: roomId, - }); - redactionEvent.setStatus(EventStatus.QUEUED); - - event.markLocallyRedacted(redactionEvent); - const { queryByLabelText } = getComponent({ mxEvent: event }); - expect(queryByLabelText("Delete")).toBeTruthy(); - }); - - it("renders cancel and retry button for an event with NOT_SENT status", () => { - alicesMessageEvent.setStatus(EventStatus.NOT_SENT); - const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); - expect(queryByLabelText("Retry")).toBeTruthy(); - expect(queryByLabelText("Delete")).toBeTruthy(); - }); - - it("only shows retry and delete buttons when event could not be sent", () => { - // Enable pin and other features - jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); - - alicesMessageEvent.setStatus(EventStatus.NOT_SENT); - const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); - - // Should show retry and cancel buttons - expect(queryByLabelText("Retry")).toBeTruthy(); - expect(queryByLabelText("Delete")).toBeTruthy(); - - // Should NOT show edit, pin, react, reply buttons - expect(queryByLabelText("Edit")).toBeFalsy(); - expect(queryByLabelText("Pin")).toBeFalsy(); - expect(queryByLabelText("React")).toBeFalsy(); - expect(queryByLabelText("Reply")).toBeFalsy(); - expect(queryByLabelText("Reply in thread")).toBeFalsy(); - }); - - it.todo("unsends event on cancel click"); - it.todo("retrys event on retry click"); - }); - - describe("thread button", () => { - beforeEach(() => { - Thread.setServerSideSupport(FeatureSupport.Stable); - }); - - describe("when threads feature is enabled", () => { - it("renders thread button on own actionable event", () => { - const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); - expect(queryByLabelText("Reply in thread")).toBeTruthy(); - }); - - it("does not render thread button for a beacon_info event", () => { - const beaconInfoEvent = makeBeaconInfoEvent(userId, roomId); - const { queryByLabelText } = getComponent({ mxEvent: beaconInfoEvent }); - expect(queryByLabelText("Reply in thread")).toBeFalsy(); - }); - - it("opens thread on click", () => { - const { getByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); - - fireEvent.click(getByLabelText("Reply in thread")); - - expect(dispatcher.dispatch).toHaveBeenCalledWith({ - action: Action.ShowThread, - rootEvent: alicesMessageEvent, - push: false, - }); - }); - - it("opens parent thread for a thread reply message", () => { - const threadReplyEvent = new MatrixEvent({ - type: EventType.RoomMessage, - sender: userId, - room_id: roomId, - content: { - msgtype: MsgType.Text, - body: "this is a thread reply", - }, - }); - // mock the thread stuff - jest.spyOn(threadReplyEvent, "isThreadRoot", "get").mockReturnValue(false); - // set alicesMessageEvent as the root event - jest.spyOn(threadReplyEvent, "getThread").mockReturnValue({ - rootEvent: alicesMessageEvent, - } as unknown as Thread); - const { getByLabelText } = getComponent({ mxEvent: threadReplyEvent }); - - fireEvent.click(getByLabelText("Reply in thread")); - - expect(dispatcher.dispatch).toHaveBeenCalledWith({ - action: Action.ShowThread, - rootEvent: alicesMessageEvent, - initialEvent: threadReplyEvent, - highlighted: true, - scroll_into_view: true, - push: false, - }); - }); - }); - }); - - it.each([["React"], ["Reply"], ["Reply in thread"], ["Edit"], ["Pin"]])( - "does not show context menu when right-clicking", - (buttonLabel: string) => { - // For favourite and pin buttons - jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); - - const event = new MouseEvent("contextmenu", { - bubbles: true, - cancelable: true, - }); - event.stopPropagation = jest.fn(); - event.preventDefault = jest.fn(); - - const { queryByTestId, queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); - fireEvent(queryByLabelText(buttonLabel)!, event); - expect(event.stopPropagation).toHaveBeenCalled(); - expect(event.preventDefault).toHaveBeenCalled(); - expect(queryByTestId("mx_MessageContextMenu")).toBeFalsy(); - }, - ); - - it("does shows context menu when right-clicking options", () => { - const { queryByTestId, queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); - fireEvent.contextMenu(queryByLabelText("Options")!); - expect(queryByTestId("mx_MessageContextMenu")).toBeTruthy(); - }); - - describe("pin button", () => { - beforeEach(() => { - // enable pin button - jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); - jest.spyOn(PinningUtils, "isPinned").mockReturnValue(false); - }); - - afterEach(() => { - jest.spyOn( - room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, - "mayClientSendStateEvent", - ).mockRestore(); - }); - - it("should not render pin button when user can't send state event", () => { - jest.spyOn( - room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, - "mayClientSendStateEvent", - ).mockReturnValue(false); - - const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); - expect(queryByLabelText("Pin")).toBeFalsy(); - }); - - it("should render pin button", () => { - const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); - expect(queryByLabelText("Pin")).toBeTruthy(); - }); - - it("should listen to room pinned events", async () => { - getComponent({ mxEvent: alicesMessageEvent }); - expect(screen.getByLabelText("Pin")).toBeInTheDocument(); - - // Event is considered pinned - jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true); - // Emit that the room pinned events have changed - const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!; - roomState.emit( - RoomStateEvent.Events, - { - getType: () => EventType.RoomPinnedEvents, - } as MatrixEvent, - roomState, - null, - ); - - await waitFor(() => expect(screen.getByLabelText("Unpin")).toBeInTheDocument()); - }); - }); - - describe("expand/collapse quote buttons", () => { - it.each([ - ["expand", false], - ["collapse", true], - ])("should render %s", (state, value) => { - const { getByLabelText } = getComponent({ - mxEvent: new MatrixEvent({ - type: EventType.RoomMessage, - sender: userId, - room_id: roomId, - content: { - "msgtype": MsgType.Text, - "body": "Hello", - "m.relates_to": { - "m.in_reply_to": { event_id: alicesMessageEvent.getId() }, - }, - }, - event_id: "$alices_reply", - }), - isQuoteExpanded: value, - }); - expect(getByLabelText(`${state[0].toUpperCase()}${state.slice(1)} quotes`)).toBeInTheDocument(); - }); - }); -}); diff --git a/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx b/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx index ce79f18efe..5559647bd3 100644 --- a/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx +++ b/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx @@ -29,16 +29,12 @@ jest.mock("../../../../../src/components/views/messages/MImageBody", () => ({ default: () =>
, })); -jest.mock("../../../../../src/components/views/messages/MVideoBody", () => ({ - __esModule: true, - default: () =>
, -})); - jest.mock("../../../../../src/components/views/messages/MBodyFactory", () => ({ __esModule: true, DecryptionFailureBodyFactory: () =>
, FileBodyFactory: () =>
, RedactedBodyFactory: () =>
Message deleted by Moderator
, + VideoBodyFactory: () =>
@@ -310,7 +216,7 @@ exports[` should render 1`] = `
-
@@ -462,9 +321,9 @@ exports[` should render 1`] = ` class="_container_udcm8_10" > should render 1`] = ` > Modern layout must be selected to use this feature. diff --git a/apps/web/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AppearanceUserSettingsTab-test.tsx.snap b/apps/web/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AppearanceUserSettingsTab-test.tsx.snap index f78cf65ae3..c047a26444 100644 --- a/apps/web/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AppearanceUserSettingsTab-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AppearanceUserSettingsTab-test.tsx.snap @@ -254,53 +254,6 @@ exports[`AppearanceUserSettingsTab should render 1`] = ` Hey you. You're the best! - @@ -312,7 +265,7 @@ exports[`AppearanceUserSettingsTab should render 1`] = `