Merge branch 'develop' into andybalaam/always-nag-to-verify-even-if-no-e2e-rooms

This commit is contained in:
Andy Balaam 2026-04-01 14:54:22 +01:00
commit 9046715dc3
124 changed files with 7821 additions and 3521 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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 != ''

View File

@ -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

View File

@ -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<void> {
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(

View File

@ -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"
}

View File

@ -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",

View File

@ -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": {

View File

@ -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

View File

@ -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" },

View File

@ -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

View File

@ -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
}

View File

@ -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();
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -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";

View File

@ -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;
}
}

View File

@ -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%;
}
}
}

View File

@ -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 */
}
}
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}

View File

@ -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 ? <Spinner size={18} /> : undefined;
const classes = classNames({
mx_MessageActionBar_iconButton: true,
mx_MessageActionBar_downloadButton: true,
mx_MessageActionBar_downloadSpinnerButton: !!spinner,
});
return (
<RovingAccessibleButton
className={classes}
title={buttonTitle}
onClick={download}
disabled={loading}
placement="left"
>
<DownloadIcon />
{spinner}
</RovingAccessibleButton>
);
}

View File

@ -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<IProps, ISta
private content = createRef<HTMLDivElement>();
private EventContentBodyViewModel: EventContentBodyViewModel;
private editHistoryActionBarViewModel: EditHistoryActionBarViewModel;
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
@ -72,6 +72,13 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
linkify: true,
client: cli,
});
this.editHistoryActionBarViewModel = new EditHistoryActionBarViewModel({
canRemove: !props.mxEvent.isRedacted() && !props.isBaseEvent && canRedact,
showViewSource: SettingsStore.getValue("developerMode"),
onRemoveClick: this.onRedactClick,
onViewSourceClick: this.onViewSourceClick,
});
}
public componentDidUpdate(prevProps: IProps): void {
@ -79,6 +86,13 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
const mxEventContent = getReplacedContent(this.props.mxEvent);
this.EventContentBodyViewModel.setEventContent(this.props.mxEvent, mxEventContent);
}
this.editHistoryActionBarViewModel.setProps({
canRemove: !this.props.mxEvent.isRedacted() && !this.props.isBaseEvent && this.state.canRedact,
showViewSource: SettingsStore.getValue("developerMode"),
onRemoveClick: this.onRedactClick,
onViewSourceClick: this.onViewSourceClick,
});
}
private onAssociatedStatusChanged = (): void => {
@ -116,34 +130,20 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
const event = this.props.mxEvent;
event.localRedactionEvent()?.off(MatrixEventEvent.Status, this.onAssociatedStatusChanged);
this.EventContentBodyViewModel.dispose();
this.editHistoryActionBarViewModel.dispose();
}
private renderActionBar(): React.ReactNode {
// hide the button when already redacted
let redactButton: JSX.Element | undefined;
if (!this.props.mxEvent.isRedacted() && !this.props.isBaseEvent && this.state.canRedact) {
redactButton = <AccessibleButton onClick={this.onRedactClick}>{_t("action|remove")}</AccessibleButton>;
}
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 = (
<AccessibleButton onClick={this.onViewSourceClick}>{_t("action|view_source")}</AccessibleButton>
);
}
if (!redactButton && !viewSourceButton) {
// Hide the empty MessageActionBar
return null;
} else {
// disabled remove button when not allowed
return (
<div className="mx_MessageActionBar">
{redactButton}
{viewSourceButton}
</div>
);
}
return (
<ActionBarView vm={this.editHistoryActionBarViewModel} className="mx_ThreadActionBar mx_HistoryActionBar" />
);
}
public render(): React.ReactNode {

View File

@ -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<IProps> = ({ mxEvent }) => {
const [mediaIsVisible, setVisible] = useMediaVisible(mxEvent);
if (!mediaIsVisible) {
return;
}
return (
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton "
title={_t("action|hide")}
onClick={() => setVisible(false)}
placement="left"
>
<VisibilityOffIcon />
</RovingAccessibleButton>
);
};

View File

@ -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<IBodyProps>;
@ -59,6 +62,78 @@ export function FileBodyFactory({
return <FileBodyView vm={vm} refIFrame={refIFrame} refLink={refLink} className="mx_MFileBody" />;
}
export function VideoBodyFactory({
mxEvent,
mediaEventHelper,
forExport,
inhibitInteraction,
}: Readonly<Pick<IBodyProps, "mxEvent" | "mediaEventHelper" | "forExport" | "inhibitInteraction">>): JSX.Element {
const { timelineRenderingType } = useContext(RoomContext);
const [mediaVisible, setMediaVisible] = useMediaVisible(mxEvent);
const videoRef = useRef<HTMLVideoElement>(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 (
<VideoBodyView
vm={vm}
className="mx_MVideoBody"
containerClassName="mx_MVideoBody_container"
videoRef={videoRef}
>
{showFileBody ? (
<FileBodyFactory
mxEvent={mxEvent}
mediaEventHelper={mediaEventHelper}
forExport={forExport}
showFileInfo={false}
/>
) : null}
</VideoBodyView>
);
}
export function RedactedBodyFactory({ mxEvent, ref }: Pick<IBodyProps, "mxEvent" | "ref">): JSX.Element {
const vm = useCreateAutoDisposedViewModel(() => new RedactedBodyViewModel({ mxEvent }));
@ -87,9 +162,11 @@ export function DecryptionFailureBodyFactory({ mxEvent, ref }: Pick<IBodyProps,
return <DecryptionFailureBodyView vm={vm} ref={ref} className="mx_DecryptionFailureBody mx_EventTile_content" />;
}
// Message body factory registry.
// Start small: only m.file currently routes to the new FileBodyView path.
const MESSAGE_BODY_TYPES = new Map<string, MBodyComponent>([[MsgType.File, FileBodyFactory]]);
// Message body factory registry for bodies that already route through view-model-backed wrappers.
const MESSAGE_BODY_TYPES = new Map<string, MBodyComponent>([
[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.

View File

@ -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<IProps, IState> {
public static contextType = RoomContext;
declare public context: React.ContextType<typeof RoomContext>;
private videoRef = React.createRef<HTMLVideoElement>();
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<MediaEventContent>();
// 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<MediaEventContent>();
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<MediaEventContent>();
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<void> {
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<MediaEventContent>();
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<void> {
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<IProps>): Promise<void> {
if (!prevProps.mediaVisible && this.props.mediaVisible) {
await this.downloadVideo();
}
}
public componentWillUnmount(): void {
SettingsStore.unwatchSetting(this.sizeWatcher);
}
private videoOnPlay = async (): Promise<void> => {
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 = <div style={{ width: maxWidth, height: maxHeight }} />;
if (this.state.error !== null) {
return (
<MediaProcessingError className="mx_MVideoBody">
{_t("timeline|m.video|error_decrypting")}
</MediaProcessingError>
);
}
// Users may not even want to show a poster, so instead show a preview button.
if (!this.props.mediaVisible) {
return (
<span className="mx_MVideoBody">
<div
className="mx_MVideoBody_container"
style={{ width: maxWidth, height: maxHeight, aspectRatio }}
>
<HiddenMediaPlaceholder onClick={this.onClick}>
{_t("timeline|m.video|show_video")}
</HiddenMediaPlaceholder>
</div>
</span>
);
}
// 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 (
<span className="mx_MVideoBody">
<div className="mx_MVideoBody_container" style={{ maxWidth, maxHeight, aspectRatio }}>
<InlineSpinner />
</div>
{spaceFiller}
</span>
);
}
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 (
<span className="mx_MVideoBody">
<div className="mx_MVideoBody_container" style={{ maxWidth, maxHeight, aspectRatio }}>
<video
className="mx_MVideoBody"
ref={this.videoRef}
src={contentUrl}
title={content.body}
controls={!this.props.inhibitInteraction}
// Disable downloading as it doesn't work with e2ee video,
// users should use the dedicated Download button in the Message Action Bar
controlsList="nodownload"
// The video uses a cross-origin request.
// Firefox explicitly bypasses services workers for crossorigin
// video elements without crossorigin attribute.
crossOrigin="anonymous"
preload={preload}
muted={autoplay}
autoPlay={autoplay}
poster={poster}
onPlay={this.videoOnPlay}
/>
{spaceFiller}
</div>
{fileBody}
</span>
);
}
}
// Wrap MVideoBody component so we can use a hook here.
const MVideoBody: React.FC<IBodyProps> = (props) => {
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent);
return <MVideoBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
};
export default MVideoBody;

View File

@ -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<IOptionsButtonProps> = ({
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 = (
<MessageContextMenu
{...aboveLeftOf(buttonRect)}
mxEvent={mxEvent}
permalinkCreator={permalinkCreator}
eventTileOps={tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined}
collapseReplyChain={replyChain?.canCollapse() ? replyChain.collapse : undefined}
onFinished={closeMenu}
getRelationsForEvent={getRelationsForEvent}
/>
);
}
return (
<React.Fragment>
<ContextMenuTooltipButton
className="mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
title={_t("common|options")}
onClick={onOptionsClick}
onContextMenu={onOptionsClick}
isExpanded={menuDisplayed}
ref={buttonRefCallback}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
placement="top"
>
<OverflowHorizontalIcon />
</ContextMenuTooltipButton>
{contextMenu}
</React.Fragment>
);
};
interface IReactButtonProps {
mxEvent: MatrixEvent;
reactions?: Relations | null | undefined;
onFocusChange: (menuDisplayed: boolean) => void;
}
const ReactButton: React.FC<IReactButtonProps> = ({ 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 = (
<ContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu} managed={false} focusLock>
<ReactionPicker mxEvent={mxEvent} reactions={reactions} onFinished={closeMenu} />
</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 (
<React.Fragment>
<ContextMenuTooltipButton
className="mx_MessageActionBar_iconButton"
title={_t("action|react")}
onClick={onClick}
onContextMenu={onClick}
isExpanded={menuDisplayed}
ref={buttonRefCallback}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
placement="top"
>
<ReactionAddIcon />
</ContextMenuTooltipButton>
{contextMenu}
</React.Fragment>
);
};
interface IReplyInThreadButton {
mxEvent: MatrixEvent;
}
const ReplyInThreadButton: React.FC<IReplyInThreadButton> = ({ 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<ShowThreadPayload>({
action: Action.ShowThread,
rootEvent: thread.rootEvent,
initialEvent: mxEvent,
scroll_into_view: true,
highlighted: true,
push: context.isCard,
});
} else {
defaultDispatcher.dispatch<ShowThreadPayload>({
action: Action.ShowThread,
rootEvent: mxEvent,
push: context.isCard,
});
}
};
const title = !hasARelation ? _t("action|reply_in_thread") : _t("threads|error_start_thread_existing_relation");
return (
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton mx_MessageActionBar_threadButton"
disabled={hasARelation}
title={title}
onClick={onClick}
onContextMenu={onClick}
placement="top"
>
<ThreadsIcon />
</RovingAccessibleButton>
);
};
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<IMessageActionBarProps> {
public static contextType = RoomContext;
declare public context: React.ContextType<typeof RoomContext>;
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<void> => {
// 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(
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"
title={_t("action|edit")}
onClick={this.onEditClick}
onContextMenu={this.onEditClick}
key="edit"
placement="top"
>
<EditIcon />
</RovingAccessibleButton>,
);
}
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(
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"
title={isPinned ? _t("action|unpin") : _t("action|pin")}
onClick={(e: ButtonEvent) => this.onPinClick(e, isPinned)}
onContextMenu={(e: ButtonEvent) => this.onPinClick(e, isPinned)}
key="pin"
placement="top"
>
{isPinned ? <UnpinIcon /> : <PinIcon />}
</RovingAccessibleButton>,
);
}
const cancelSendingButton = (
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"
title={_t("action|delete")}
onClick={this.onCancelClick}
onContextMenu={this.onCancelClick}
key="cancel"
placement="top"
>
<DeleteIcon />
</RovingAccessibleButton>
);
const threadTooltipButton = <ReplyInThreadButton mxEvent={this.props.mxEvent} key="reply_thread" />;
// 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,
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton mx_MessageActionBar_retryButton"
title={_t("action|retry")}
onClick={this.onResendClick}
onContextMenu={this.onResendClick}
key="resend"
placement="top"
>
<RestartIcon />
</RovingAccessibleButton>,
);
// 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,
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"
title={_t("action|reply")}
onClick={this.onReplyClick}
onContextMenu={this.onReplyClick}
key="reply"
placement="top"
>
<ReplyIcon />
</RovingAccessibleButton>,
);
}
// 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,
<ReactButton
mxEvent={this.props.mxEvent}
reactions={this.props.reactions}
onFocusChange={this.onFocusChange}
key="react"
/>,
);
}
// 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,
<DownloadActionButton
mxEvent={this.props.mxEvent}
mediaEventHelperGet={() => this.props.getTile()?.getMediaHelper?.()}
key="download"
/>,
);
}
if (MediaEventHelper.canHide(this.props.mxEvent)) {
toolbarOpts.splice(0, 0, <HideActionButton mxEvent={this.props.mxEvent} key="hide" />);
}
} 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(
<RovingAccessibleButton
className={expandClassName}
title={
this.props.isQuoteExpanded
? _t("timeline|mab|collapse_reply_chain")
: _t("timeline|mab|expand_reply_chain")
}
caption={_t(ALTERNATE_KEY_NAME[Key.SHIFT]) + " + " + _t("action|click")}
onClick={this.props.toggleThreadExpanded}
key="expand"
placement="top"
>
{this.props.isQuoteExpanded ? <CollapseIcon /> : <ExpandIcon />}
</RovingAccessibleButton>,
);
}
// The menu button should be last, so dump it there.
toolbarOpts.push(
<OptionsButton
mxEvent={this.props.mxEvent}
getReplyChain={this.props.getReplyChain}
getTile={this.props.getTile}
permalinkCreator={this.props.permalinkCreator}
onFocusChange={this.onFocusChange}
key="menu"
getRelationsForEvent={this.props.getRelationsForEvent}
/>,
);
}
// aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive.
return (
<Toolbar className="mx_MessageActionBar" aria-label={_t("timeline|mab|label")} aria-live="off">
{toolbarOpts}
</Toolbar>
);
}
}

View File

@ -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<IBodyProps, "onMessageAllowed" | "mediaEventHelper"> {
@ -65,7 +70,7 @@ const baseBodyTypes = new Map<string, React.ComponentType<IBodyProps>>([
[MsgType.Image, MImageBody],
[MsgType.File, (props: IBodyProps) => renderMBody(props, FileBodyFactory)!],
[MsgType.Audio, MVoiceOrAudioBody],
[MsgType.Video, MVideoBody],
[MsgType.Video, VideoBodyFactory],
]);
const baseEvTypes = new Map<string, React.ComponentType<IBodyProps>>([
[EventType.Sticker, MStickerBody],
@ -260,7 +265,8 @@ export default class MessageEvent extends React.Component<IProps> 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)!;

View File

@ -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<EventTileProps, IState>
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<EventTileProps, IState>
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<EventTileProps, IState>
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<EventTileProps, IState>
}
}
private viewInRoom = (evt: ButtonEvent): void => {
evt.preventDefault();
evt.stopPropagation();
private readonly onViewInRoomClick = (_anchor: HTMLElement | null): void => {
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
event_id: this.props.mxEvent.getId(),
@ -573,16 +575,14 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
});
};
private copyLinkToThread = async (evt: ButtonEvent): Promise<void> => {
evt.preventDefault();
evt.stopPropagation();
private readonly onCopyLinkToThreadClick = async (_anchor: HTMLElement | null): Promise<void> => {
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<EventTileProps, IState>
/** 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<EventTileProps, IState>
return !!(actions?.tweaks.highlight || previousActions?.tweaks.highlight);
}
private onSenderProfileClick = (): void => {
private readonly onSenderProfileClick = (): void => {
dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert,
userId: this.props.mxEvent.getSender()!,
@ -740,7 +740,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
});
};
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<EventTileProps, IState>
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<HTMLElement>): 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<HTMLElement>): 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<EventTileProps, IState>
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<EventTileProps, IState>
});
};
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<EventTileProps, IState>
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<EventTileProps, IState>
}
}
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 ? (
<MessageActionBar
<ActionBarWrapper
mxEvent={this.props.mxEvent}
reactions={this.state.reactions}
permalinkCreator={this.props.permalinkCreator}
@ -1286,8 +1312,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
"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,
},
[
<div className="mx_EventTile_senderDetails" key="mx_EventTile_senderDetails">
@ -1348,15 +1374,15 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
"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<ShowThreadPayload>({
@ -1411,9 +1437,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
{this.renderThreadPanelSummary()}
</div>
{this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && (
<EventTileThreadToolbar
viewInRoom={this.viewInRoom}
copyLinkToThread={this.copyLinkToThread}
<ThreadListActionBarWrapper
onViewInRoomClick={this.onViewInRoomClick}
onCopyLinkClick={this.onCopyLinkToThreadClick}
/>
)}
@ -1481,8 +1507,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
"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<ReactionsRowWrappe
</>
);
}
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<void>;
}
function ThreadListActionBarWrapper({
onViewInRoomClick,
onCopyLinkClick,
}: Readonly<ThreadListActionBarWrapperProps>): JSX.Element {
const vm = useCreateAutoDisposedViewModel(
() =>
new ThreadListActionBarViewModel({
onViewInRoomClick,
onCopyLinkClick,
}),
);
useEffect(() => {
vm.setProps({
onViewInRoomClick,
onCopyLinkClick,
});
}, [vm, onViewInRoomClick, onCopyLinkClick]);
return <ActionBarView vm={vm} className="mx_ThreadActionBar" />;
}
function ActionBarWrapper({
mxEvent,
reactions,
permalinkCreator,
getTile,
getReplyChain,
onFocusChange,
isQuoteExpanded,
toggleThreadExpanded,
getRelationsForEvent,
}: Readonly<ActionBarWrapperProps>): JSX.Element {
const roomContext = useContext(RoomContext);
const { isCard } = useContext(CardContext);
const [optionsMenuAnchorRect, setOptionsMenuAnchorRect] = useState<DOMRect | null>(null);
const [reactionsMenuAnchorRect, setReactionsMenuAnchorRect] = useState<DOMRect | null>(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 (
<>
<ActionBarView vm={vm} className="mx_MessageActionBar" />
{optionsMenuAnchorRect ? (
<MessageContextMenu
{...aboveLeftOf(optionsMenuAnchorRect)}
mxEvent={mxEvent}
permalinkCreator={permalinkCreator}
eventTileOps={eventTileOps}
collapseReplyChain={collapseReplyChain}
onFinished={closeOptionsMenu}
getRelationsForEvent={getRelationsForEvent}
/>
) : null}
{reactionsMenuAnchorRect ? (
<ContextMenu
{...aboveLeftOf(reactionsMenuAnchorRect)}
onFinished={closeReactionsMenu}
managed={false}
focusLock
>
<ReactionPicker mxEvent={mxEvent} reactions={reactions} onFinished={closeReactionsMenu} />
</ContextMenu>
) : null}
</>
);
}

View File

@ -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 (
<Toolbar className="mx_MessageActionBar" aria-label={_t("timeline|mab|label")} aria-live="off">
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"
onClick={viewInRoom}
title={_t("timeline|mab|view_in_room")}
key="view_in_room"
>
<VisibilityOnIcon />
</RovingAccessibleButton>
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"
onClick={copyLinkToThread}
title={_t("timeline|mab|copy_link_thread")}
key="copy_link_to_thread"
>
<LinkIcon />
</RovingAccessibleButton>
</Toolbar>
);
}

View File

@ -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,
];
}

View File

@ -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": {

View File

@ -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`

View File

@ -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<EmptyObject> {
/**
* 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<string, Filter> = 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<EmptyObject> {
*/
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<EmptyObject> {
protected async onReady(): Promise<any> {
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<EmptyObject> {
const room = payload.room;
this.roomSkipList.removeRoom(room);
this.scheduleEmit();
break;
}
}
@ -300,7 +339,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
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<EmptyObject> {
.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<EmptyObject> {
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 {

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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

View File

@ -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,

View File

@ -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<string, boolean>,
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<void> {
const eventId = mxEvent.getId();
if (!eventId) return;
const eventVisibility = SettingsStore.getValue("showMediaEventIds");
await SettingsStore.setValue("showMediaEventIds", null, SettingLevel.DEVICE, {
...eventVisibility,
[eventId]: visible,
});
}

View File

@ -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<ActionBarViewSnapshot, EditHistoryActionBarViewModelProps>
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<EditHistoryActionBarViewModelProps>): 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);
};
}

View File

@ -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<HTMLVideoElement | null>;
}
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<VideoBodyViewSnapshot, VideoBodyViewModelProps>
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<MediaEventContent>().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<MediaEventContent>().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<MediaEventContent>();
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<MediaEventContent>();
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<MediaEventContent>();
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<MediaEventContent>().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<MediaEventContent>());
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<void> {
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<MediaEventContent>();
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<void> => {
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();
}
};
}

View File

@ -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<RoomListSectionHeaderViewSnapshot, RoomListSectionHeaderViewModelProps>
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;
}
}

View File

@ -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<FilterKey, FilterId> = 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<FilterEnum, FilterId> = 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<string, string> = {
[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<RoomListViewSnapshot, RoomListViewModelProps>
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<string, RoomListItemViewModel>();
private readonly roomItemViewModels = new Map<string, RoomListItemViewModel>();
// 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<string, Room>();
// Don't clear section vm because we want to keep the expand/collapse state even during space changes.
private readonly roomSectionHeaderViewModels = new Map<string, RoomListSectionHeaderViewModel>();
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),
}));
}

View File

@ -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<ActionBarViewSnapshot, EventTileActionBarViewModelProps>
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<typeof MatrixClientPeg.safeGet>,
): 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<typeof MatrixClientPeg.safeGet>,
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<void> {
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<EventTileActionBarViewModelProps>): 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<void> => {
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<void> => {
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<ShowThreadPayload>({
action: Action.ShowThread,
rootEvent: thread.rootEvent,
initialEvent: mxEvent,
scroll_into_view: true,
highlighted: true,
push: isCard,
});
return;
}
defaultDispatcher.dispatch<ShowThreadPayload>({
action: Action.ShowThread,
rootEvent: mxEvent,
push: isCard,
});
};
}

View File

@ -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<ActionBarViewSnapshot, ThreadListActionBarViewModelProps>
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<ThreadListActionBarViewModelProps>): 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);
};
}

View File

@ -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 }));

View File

@ -86,15 +86,23 @@ exports[`<MessageEditHistory /> should match the snapshot 1`] = `
</span>
</div>
<div
class="mx_MessageActionBar"
aria-label="Message Actions"
aria-live="off"
class="_flex_4dswl_9 mx_ThreadActionBar mx_HistoryActionBar _toolbar_1ax4y_8"
role="toolbar"
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="mx_AccessibleButton"
<button
aria-label="Remove"
class="_button_13vu4_8 _toolbar_item_1ax4y_14"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="0"
>
Remove
</div>
</button>
</div>
</div>
</div>
@ -224,15 +232,23 @@ exports[`<MessageEditHistory /> should support events with 1`] = `
</span>
</div>
<div
class="mx_MessageActionBar"
aria-label="Message Actions"
aria-live="off"
class="_flex_4dswl_9 mx_ThreadActionBar mx_HistoryActionBar _toolbar_1ax4y_8"
role="toolbar"
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="mx_AccessibleButton"
<button
aria-label="Remove"
class="_button_13vu4_8 _toolbar_item_1ax4y_14"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="0"
>
Remove
</div>
</button>
</div>
</div>
</div>
@ -278,15 +294,23 @@ exports[`<MessageEditHistory /> should support events with 1`] = `
</span>
</div>
<div
class="mx_MessageActionBar"
aria-label="Message Actions"
aria-live="off"
class="_flex_4dswl_9 mx_ThreadActionBar mx_HistoryActionBar _toolbar_1ax4y_8"
role="toolbar"
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="mx_AccessibleButton"
<button
aria-label="Remove"
class="_button_13vu4_8 _toolbar_item_1ax4y_14"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="0"
>
Remove
</div>
</button>
</div>
</div>
</div>
@ -314,15 +338,23 @@ exports[`<MessageEditHistory /> should support events with 1`] = `
</span>
</div>
<div
class="mx_MessageActionBar"
aria-label="Message Actions"
aria-live="off"
class="_flex_4dswl_9 mx_ThreadActionBar mx_HistoryActionBar _toolbar_1ax4y_8"
role="toolbar"
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="mx_AccessibleButton"
<button
aria-label="Remove"
class="_button_13vu4_8 _toolbar_item_1ax4y_14"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="0"
>
Remove
</div>
</button>
</div>
</div>
</div>
@ -332,7 +364,7 @@ exports[`<MessageEditHistory /> should support events with 1`] = `
</div>
</div>
<div
aria-describedby="_r_8_"
aria-describedby="_r_c_"
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"

View File

@ -1,155 +0,0 @@
/*
Copyright 2024 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 { mocked } from "jest-mock";
import fetchMock from "@fetch-mock/jest";
import { fireEvent, render, screen, waitFor } from "jest-matrix-react";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import userEvent from "@testing-library/user-event";
import { clearAllModals, stubClient } from "../../../../test-utils";
import DownloadActionButton from "../../../../../src/components/views/messages/DownloadActionButton";
import Modal from "../../../../../src/Modal";
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
import ErrorDialog from "../../../../../src/components/views/dialogs/ErrorDialog";
jest.mock("matrix-encrypt-attachment", () => ({
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(<DownloadActionButton mxEvent={plainEvent} mediaEventHelperGet={() => 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(<DownloadActionButton mxEvent={event} mediaEventHelperGet={() => 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(<DownloadActionButton mxEvent={plainEvent} mediaEventHelperGet={() => 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(<DownloadActionButton mxEvent={e2eEvent} mediaEventHelperGet={() => mediaEventHelper} />);
const button = screen.getByRole("button");
await user.hover(button);
await user.click(button);
await waitFor(() => {
expect(screen.getByRole("tooltip")).toHaveTextContent("Decrypting");
});
});
});

View File

@ -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<MatrixClient>;
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(<HideActionButton mxEvent={event} />, withClientContextRenderOptions(cli));
expect(screen.getByRole("button")).toBeVisible();
});
it("should show button when event is visible by mediaPreviewConfig setting", async () => {
mockSetting(MediaPreviewValue.On, {});
render(<HideActionButton mxEvent={event} />, 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(<HideActionButton mxEvent={event} />, withClientContextRenderOptions(cli));
expect(screen.queryByRole("button")).toBeNull();
});
it("should hide button when event is hidden by showImages setting", async () => {
mockSetting(MediaPreviewValue.Off, {});
render(<HideActionButton mxEvent={event} />, withClientContextRenderOptions(cli));
expect(screen.queryByRole("button")).toBeNull();
});
it("should store event as hidden when clicked", async () => {
const spy = jest.spyOn(SettingsStore, "setValue");
render(<HideActionButton mxEvent={event} />, 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();
});
});

View File

@ -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({

View File

@ -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(
<MatrixClientContext.Provider value={cli}>
<MVideoBody {...defaultProps} />
<VideoBodyFactory {...defaultProps} />
</MatrixClientContext.Provider>,
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(
<MVideoBody mxEvent={encryptedMediaEvent} mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)} />,
render(
<VideoBodyFactory
mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>,
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(
<MVideoBody
<VideoBodyFactory
mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>,
@ -177,7 +179,7 @@ describe("MVideoBody", () => {
fetchMock.getOnce(thumbUrl, { status: 200 });
render(
<MVideoBody
<VideoBodyFactory
mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>,
@ -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(
<MVideoBody
render(
<VideoBodyFactory
mxEvent={ourEncryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(ourEncryptedMediaEvent)}
/>,
withClientContextRenderOptions(cli),
);
expect(await screen.findByLabelText("alt for a test video")).toBeInTheDocument();
expect(fetchMock).toHaveFetched(thumbUrl);
expect(asFragment()).toMatchSnapshot();
});
});
});

View File

@ -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("<MessageActionBar />", () => {
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<string, any> = {};
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<RoomContextType> = {}) =>
render(
<ScopedRoomContextProvider {...defaultRoomContext} {...roomContext}>
<MessageActionBar {...defaultProps} {...props} />
</ScopedRoomContextProvider>,
);
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();
});
});
});

View File

@ -29,16 +29,12 @@ jest.mock("../../../../../src/components/views/messages/MImageBody", () => ({
default: () => <div data-testid="image-body" />,
}));
jest.mock("../../../../../src/components/views/messages/MVideoBody", () => ({
__esModule: true,
default: () => <div data-testid="video-body" />,
}));
jest.mock("../../../../../src/components/views/messages/MBodyFactory", () => ({
__esModule: true,
DecryptionFailureBodyFactory: () => <div data-testid="decryption-failure-body" />,
FileBodyFactory: () => <div data-testid="file-body" />,
RedactedBodyFactory: () => <div className="mx_RedactedBody">Message deleted by Moderator</div>,
VideoBodyFactory: () => <video data-testid="video-body" />,
renderMBody: () => <div data-testid="file-body" />,
}));
@ -47,6 +43,11 @@ jest.mock("../../../../../src/components/views/messages/MImageReplyBody", () =>
default: () => <div data-testid="image-reply-body" />,
}));
jest.mock("../../../../../src/hooks/useMediaVisible", () => ({
__esModule: true,
useMediaVisible: () => [true, jest.fn()],
}));
jest.mock("../../../../../src/components/views/messages/MStickerBody", () => ({
__esModule: true,
default: () => <div data-testid="sticker-body" />,
@ -164,11 +165,11 @@ describe("MessageEvent", () => {
result.getByTestId("textual-body");
});
it("should render a TextualBody and an VideoBody", () => {
it("should render a TextualBody and a video element", () => {
event = createEvent("video/mp4", "video.mp4", MsgType.Video);
result = renderMessageEvent();
mockMedia();
result.getByTestId("video-body");
expect(result.container.querySelector("video")).not.toBeNull();
result.getByTestId("textual-body");
});

View File

@ -79,44 +79,6 @@ exports[`MBodyFactory renderMBody fallback shows m.file generic placeholder when
</div>
`;
exports[`MBodyFactory renderMBody fallback shows m.video generic placeholder when showFileInfo is true 1`] = `
<div>
<span
class="_content_f1s5h_8 mx_MFileBody"
>
<div
class="mx_MediaBody _mediaBody_rgndh_8"
data-type="info"
>
<button
aria-label="alt"
class="_button_13vu4_8 _has-icon_13vu4_60"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
/>
</svg>
<span>
alt
</span>
</button>
</div>
</span>
</div>
`;
exports[`MBodyFactory renderMBody renders download button for m.file in file rendering type 1`] = `
<div>
<span

View File

@ -1,78 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`MVideoBody does not crash when given portrait dimensions 1`] = `
<DocumentFragment>
<span
class="mx_MVideoBody"
>
<div
class="mx_MVideoBody_container"
style="max-width: 182px; max-height: 324px; aspect-ratio: 720/1280;"
>
<video
class="mx_MVideoBody"
controls=""
controlslist="nodownload"
crossorigin="anonymous"
poster="data:image/png;base64,00"
preload="none"
/>
<div
style="width: 182px; height: 324px;"
/>
</div>
</span>
</DocumentFragment>
`;
exports[`MVideoBody should show poster for encrypted media before downloading it 1`] = `
<DocumentFragment>
<span
class="mx_MVideoBody"
>
<div
class="mx_MVideoBody_container"
style="max-width: 40px; max-height: 50px; aspect-ratio: 40/50;"
>
<video
class="mx_MVideoBody"
controls=""
controlslist="nodownload"
crossorigin="anonymous"
poster="https://server/_matrix/media/v3/download/server/encrypted-poster"
preload="none"
title="alt for a test video"
/>
<div
style="width: 40px; height: 50px;"
/>
</div>
</span>
</DocumentFragment>
`;
exports[`MVideoBody with video previews/thumbnails disabled should download video if we were the sender 1`] = `
<DocumentFragment>
<span
class="mx_MVideoBody"
>
<div
class="mx_MVideoBody_container"
style="max-width: 40px; max-height: 50px; aspect-ratio: 40/50;"
>
<video
class="mx_MVideoBody"
controls=""
controlslist="nodownload"
crossorigin="anonymous"
poster="https://server/_matrix/media/v3/download/server/encrypted-poster"
preload="none"
title="alt for a test video"
/>
<div
style="width: 40px; height: 50px;"
/>
</div>
</span>
</DocumentFragment>
`;

View File

@ -1,44 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 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 { getByLabelText, render, type RenderResult } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import React, { type ComponentProps } from "react";
import { EventTileThreadToolbar } from "../../../../../../src/components/views/rooms/EventTile/EventTileThreadToolbar";
describe("EventTileThreadToolbar", () => {
const viewInRoom = jest.fn();
const copyLink = jest.fn();
function renderComponent(props: Partial<ComponentProps<typeof EventTileThreadToolbar>> = {}): RenderResult {
return render(<EventTileThreadToolbar viewInRoom={viewInRoom} copyLinkToThread={copyLink} {...props} />);
}
afterEach(() => {
jest.resetAllMocks();
});
it("renders", () => {
const { asFragment } = renderComponent();
expect(asFragment()).toMatchSnapshot();
});
it("calls the right callbacks", async () => {
const { container } = renderComponent();
const copyBtn = getByLabelText(container, "Copy link to thread");
const viewInRoomBtn = getByLabelText(container, "View in room");
await userEvent.click(copyBtn);
expect(copyLink).toHaveBeenCalledTimes(1);
await userEvent.click(viewInRoomBtn);
expect(viewInRoom).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,49 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`EventTileThreadToolbar renders 1`] = `
<DocumentFragment>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="View in room"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 16q1.875 0 3.188-1.312Q16.5 13.375 16.5 11.5t-1.312-3.187T12 7 8.813 8.313 7.5 11.5t1.313 3.188T12 16m0-1.8q-1.125 0-1.912-.787A2.6 2.6 0 0 1 9.3 11.5q0-1.125.787-1.912A2.6 2.6 0 0 1 12 8.8q1.125 0 1.912.787.788.788.788 1.913t-.787 1.912A2.6 2.6 0 0 1 12 14.2m0 4.8q-3.475 0-6.35-1.837Q2.775 15.324 1.3 12.2a.8.8 0 0 1-.1-.312 3 3 0 0 1 0-.775.8.8 0 0 1 .1-.313q1.475-3.125 4.35-4.962Q8.525 4 12 4t6.35 1.838T22.7 10.8a.8.8 0 0 1 .1.313 3 3 0 0 1 0 .774.8.8 0 0 1-.1.313q-1.475 3.125-4.35 4.963Q15.475 19 12 19m0-2a9.54 9.54 0 0 0 5.188-1.488A9.77 9.77 0 0 0 20.8 11.5a9.77 9.77 0 0 0-3.613-4.012A9.54 9.54 0 0 0 12 6a9.55 9.55 0 0 0-5.187 1.487A9.77 9.77 0 0 0 3.2 11.5a9.77 9.77 0 0 0 3.613 4.012A9.54 9.54 0 0 0 12 17"
/>
</svg>
</div>
<div
aria-label="Copy link to thread"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 19.071q-1.467 1.467-3.536 1.467-2.067 0-3.535-1.467t-1.467-3.535q0-2.07 1.467-3.536L7.05 9.879q.3-.3.707-.3t.707.3.301.707-.3.707l-2.122 2.121a2.9 2.9 0 0 0-.884 2.122q0 1.237.884 2.12.884.885 2.121.885t2.122-.884l2.121-2.121q.3-.3.707-.3t.707.3.3.707q0 .405-.3.707zm-1.414-4.243q-.3.3-.707.301a.97.97 0 0 1-.707-.3q-.3-.3-.301-.708 0-.405.3-.707l4.243-4.242q.3-.3.707-.3t.707.3.3.707-.3.707zm6.364-.707q-.3.3-.707.3a.97.97 0 0 1-.707-.3q-.3-.3-.301-.707 0-.405.3-.707l2.122-2.121q.884-.885.884-2.121 0-1.238-.884-2.122a2.9 2.9 0 0 0-2.121-.884q-1.237 0-2.122.884l-2.121 2.122q-.3.3-.707.3a.97.97 0 0 1-.707-.3q-.3-.3-.3-.708 0-.405.3-.707L12 4.93q1.467-1.467 3.536-1.467t3.535 1.467 1.467 3.536T19.071 12z"
/>
</svg>
</div>
</div>
</DocumentFragment>
`;

View File

@ -111,53 +111,6 @@ exports[`<LayoutSwitcher /> should render 1`] = `
Hey you. You're the best!
</div>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M15.706 2.637a2 2 0 0 1 2.829 0l2.828 2.828a2 2 0 0 1 0 2.829L9.605 20.052a1 1 0 0 1-.465.263L3.483 21.73a1 1 0 0 1-1.212-1.213l1.414-5.657a1 1 0 0 1 .263-.465zm1.224 7.262L14.102 7.07l-8.544 8.544-.943 3.771 3.771-.943z"
fill-rule="evenodd"
/>
</svg>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</div>
</div>
</div>
</div>
@ -169,7 +122,7 @@ exports[`<LayoutSwitcher /> should render 1`] = `
<label
aria-label="Message bubbles"
class="_label_19upo_59"
for="radix-_r_9_"
for="radix-_r_1_"
>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
@ -179,7 +132,7 @@ exports[`<LayoutSwitcher /> should render 1`] = `
>
<input
class="_input_1ug7n_18"
id="radix-_r_9_"
id="radix-_r_1_"
name="layout"
title=""
type="radio"
@ -252,53 +205,6 @@ exports[`<LayoutSwitcher /> should render 1`] = `
Hey you. You're the best!
</div>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M15.706 2.637a2 2 0 0 1 2.829 0l2.828 2.828a2 2 0 0 1 0 2.829L9.605 20.052a1 1 0 0 1-.465.263L3.483 21.73a1 1 0 0 1-1.212-1.213l1.414-5.657a1 1 0 0 1 .263-.465zm1.224 7.262L14.102 7.07l-8.544 8.544-.943 3.771 3.771-.943z"
fill-rule="evenodd"
/>
</svg>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</div>
</div>
</div>
</div>
@ -310,7 +216,7 @@ exports[`<LayoutSwitcher /> should render 1`] = `
<label
aria-label="IRC (experimental)"
class="_label_19upo_59"
for="radix-_r_i_"
for="radix-_r_2_"
>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
@ -320,7 +226,7 @@ exports[`<LayoutSwitcher /> should render 1`] = `
>
<input
class="_input_1ug7n_18"
id="radix-_r_i_"
id="radix-_r_2_"
name="layout"
title=""
type="radio"
@ -396,53 +302,6 @@ exports[`<LayoutSwitcher /> should render 1`] = `
Hey you. You're the best!
</div>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M15.706 2.637a2 2 0 0 1 2.829 0l2.828 2.828a2 2 0 0 1 0 2.829L9.605 20.052a1 1 0 0 1-.465.263L3.483 21.73a1 1 0 0 1-1.212-1.213l1.414-5.657a1 1 0 0 1 .263-.465zm1.224 7.262L14.102 7.07l-8.544 8.544-.943 3.771 3.771-.943z"
fill-rule="evenodd"
/>
</svg>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</div>
</div>
</div>
</div>
@ -462,9 +321,9 @@ exports[`<LayoutSwitcher /> should render 1`] = `
class="_container_udcm8_10"
>
<input
aria-describedby="radix-_r_s_"
aria-describedby="radix-_r_4_"
class="_input_udcm8_24"
id="radix-_r_r_"
id="radix-_r_3_"
name="compactLayout"
role="switch"
title=""
@ -480,13 +339,13 @@ exports[`<LayoutSwitcher /> should render 1`] = `
>
<label
class="_label_19upo_59"
for="radix-_r_r_"
for="radix-_r_3_"
>
Show compact text and messages
</label>
<span
class="_message_19upo_85 _help-message_19upo_91"
id="radix-_r_s_"
id="radix-_r_4_"
>
Modern layout must be selected to use this feature.
</span>

View File

@ -254,53 +254,6 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
Hey you. You're the best!
</div>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M15.706 2.637a2 2 0 0 1 2.829 0l2.828 2.828a2 2 0 0 1 0 2.829L9.605 20.052a1 1 0 0 1-.465.263L3.483 21.73a1 1 0 0 1-1.212-1.213l1.414-5.657a1 1 0 0 1 .263-.465zm1.224 7.262L14.102 7.07l-8.544 8.544-.943 3.771 3.771-.943z"
fill-rule="evenodd"
/>
</svg>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</div>
</div>
</div>
</div>
@ -312,7 +265,7 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
<label
aria-label="Message bubbles"
class="_label_19upo_59"
for="radix-_r_c_"
for="radix-_r_4_"
>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
@ -322,7 +275,7 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
>
<input
class="_input_1ug7n_18"
id="radix-_r_c_"
id="radix-_r_4_"
name="layout"
title=""
type="radio"
@ -395,53 +348,6 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
Hey you. You're the best!
</div>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M15.706 2.637a2 2 0 0 1 2.829 0l2.828 2.828a2 2 0 0 1 0 2.829L9.605 20.052a1 1 0 0 1-.465.263L3.483 21.73a1 1 0 0 1-1.212-1.213l1.414-5.657a1 1 0 0 1 .263-.465zm1.224 7.262L14.102 7.07l-8.544 8.544-.943 3.771 3.771-.943z"
fill-rule="evenodd"
/>
</svg>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</div>
</div>
</div>
</div>
@ -453,7 +359,7 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
<label
aria-label="IRC (experimental)"
class="_label_19upo_59"
for="radix-_r_l_"
for="radix-_r_5_"
>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
@ -463,7 +369,7 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
>
<input
class="_input_1ug7n_18"
id="radix-_r_l_"
id="radix-_r_5_"
name="layout"
title=""
type="radio"
@ -539,53 +445,6 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
Hey you. You're the best!
</div>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M15.706 2.637a2 2 0 0 1 2.829 0l2.828 2.828a2 2 0 0 1 0 2.829L9.605 20.052a1 1 0 0 1-.465.263L3.483 21.73a1 1 0 0 1-1.212-1.213l1.414-5.657a1 1 0 0 1 .263-.465zm1.224 7.262L14.102 7.07l-8.544 8.544-.943 3.771 3.771-.943z"
fill-rule="evenodd"
/>
</svg>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</div>
</div>
</div>
</div>
@ -605,9 +464,9 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
class="_container_udcm8_10"
>
<input
aria-describedby="radix-_r_v_"
aria-describedby="radix-_r_7_"
class="_input_udcm8_24"
id="radix-_r_u_"
id="radix-_r_6_"
name="compactLayout"
role="switch"
title=""
@ -623,13 +482,13 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
>
<label
class="_label_19upo_59"
for="radix-_r_u_"
for="radix-_r_6_"
>
Show compact text and messages
</label>
<span
class="_message_19upo_85 _help-message_19upo_91"
id="radix-_r_v_"
id="radix-_r_7_"
>
Modern layout must be selected to use this feature.
</span>

View File

@ -35,6 +35,11 @@ describe("useMediaVisible", () => {
withClientContextRenderOptions(matrixClient),
);
}
function renderWithoutEvent() {
return renderHook(() => useMediaVisible(), withClientContextRenderOptions(matrixClient));
}
beforeEach(() => {
matrixClient = createTestClient();
room = mkStubRoom(ROOM_ID, undefined, matrixClient);
@ -57,6 +62,14 @@ describe("useMediaVisible", () => {
expect(visible).toEqual(true);
});
it("should use the global rule when no event is provided", () => {
mediaPreviewConfig.media_previews = MediaPreviewValue.Off;
expect(renderWithoutEvent().result.current[0]).toEqual(false);
mediaPreviewConfig.media_previews = MediaPreviewValue.On;
expect(renderWithoutEvent().result.current[0]).toEqual(true);
});
it("should hide media when media previews are Off", () => {
mediaPreviewConfig.media_previews = MediaPreviewValue.Off;
const [visible] = render().result.current;

View File

@ -11,7 +11,12 @@ import { mocked } from "jest-mock";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import type { RoomNotificationState } from "../../../../src/stores/notifications/RoomNotificationState";
import { LISTS_UPDATE_EVENT, RoomListStoreV3Class } from "../../../../src/stores/room-list-v3/RoomListStoreV3";
import {
CHATS_TAG,
LISTS_UPDATE_EVENT,
RoomListStoreV3Class,
type Section,
} from "../../../../src/stores/room-list-v3/RoomListStoreV3";
import { AsyncStoreWithClient } from "../../../../src/stores/AsyncStoreWithClient";
import { RecencySorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/RecencySorter";
import { mkEvent, mkMessage, mkSpace, mkStubRoom, stubClient, upsertRoomStateEvents } from "../../../test-utils";
@ -21,7 +26,7 @@ import dispatcher from "../../../../src/dispatcher/dispatcher";
import SpaceStore from "../../../../src/stores/spaces/SpaceStore";
import { MetaSpace, UPDATE_SELECTED_SPACE } from "../../../../src/stores/spaces";
import { DefaultTagID } from "../../../../src/stores/room-list-v3/skip-list/tag";
import { FilterKey } from "../../../../src/stores/room-list-v3/skip-list/filters";
import { FilterEnum } from "../../../../src/stores/room-list-v3/skip-list/filters";
import { RoomNotificationStateStore } from "../../../../src/stores/notifications/RoomNotificationStateStore";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
import { SortingAlgorithm } from "../../../../src/stores/room-list-v3/skip-list/sorters";
@ -502,7 +507,10 @@ describe("RoomListStoreV3", () => {
store.on(LISTS_UPDATE_EVENT, fn);
// The rooms which belong to the space should not be shown
const result = store.getSortedRoomsInActiveSpace().rooms.map((r) => r.roomId);
const result = store
.getSortedRoomsInActiveSpace()
.sections.flatMap((s) => s.rooms)
.map((r) => r.roomId);
for (const id of roomIds) {
expect(result).not.toContain(id);
}
@ -511,7 +519,10 @@ describe("RoomListStoreV3", () => {
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => spaceRoom.roomId);
SpaceStore.instance.emit(UPDATE_SELECTED_SPACE);
expect(fn).toHaveBeenCalled();
const result2 = store.getSortedRoomsInActiveSpace().rooms.map((r) => r.roomId);
const result2 = store
.getSortedRoomsInActiveSpace()
.sections.flatMap((s) => s.rooms)
.map((r) => r.roomId);
for (const id of roomIds) {
expect(result2).toContain(id);
}
@ -534,7 +545,9 @@ describe("RoomListStoreV3", () => {
await store.start();
// Sorted, filtered rooms should be 8, 27 and 75
const result = store.getSortedRoomsInActiveSpace([FilterKey.FavouriteFilter]).rooms;
const result = store
.getSortedRoomsInActiveSpace([FilterEnum.FavouriteFilter])
.sections.flatMap((s) => s.rooms);
expect(result).toHaveLength(3);
for (const i of [8, 27, 75]) {
expect(result).toContain(rooms[i]);
@ -569,7 +582,9 @@ describe("RoomListStoreV3", () => {
expect(fn).toHaveBeenCalled();
// Sorted, filtered rooms should be 27 and 75
const result = store.getSortedRoomsInActiveSpace([FilterKey.FavouriteFilter]).rooms;
const result = store
.getSortedRoomsInActiveSpace([FilterEnum.FavouriteFilter])
.sections.flatMap((s) => s.rooms);
expect(result).toHaveLength(2);
for (const i of [8, 75]) {
expect(result).toContain(rooms[i]);
@ -594,7 +609,9 @@ describe("RoomListStoreV3", () => {
await store.start();
// Should only give us rooms at index 8 and 27
const result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter]).rooms;
const result = store
.getSortedRoomsInActiveSpace([FilterEnum.UnreadFilter])
.sections.flatMap((s) => s.rooms);
expect(result).toHaveLength(2);
for (const i of [8, 27]) {
expect(result).toContain(rooms[i]);
@ -611,7 +628,9 @@ describe("RoomListStoreV3", () => {
await store.start();
// Since there's no unread yet, we expect zero results
let result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter]).rooms;
let result = store
.getSortedRoomsInActiveSpace([FilterEnum.UnreadFilter])
.sections.flatMap((s) => s.rooms);
expect(result).toHaveLength(0);
// Mock so that room at index 8 is marked as unread
@ -626,7 +645,7 @@ describe("RoomListStoreV3", () => {
);
// Now we expect room at index 8 to show as unread
result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter]).rooms;
result = store.getSortedRoomsInActiveSpace([FilterEnum.UnreadFilter]).sections.flatMap((s) => s.rooms);
expect(result).toHaveLength(1);
expect(result).toContain(rooms[8]);
});
@ -649,14 +668,18 @@ describe("RoomListStoreV3", () => {
await store.start();
// Should only give us rooms at index 8 and 27
const peopleRooms = store.getSortedRoomsInActiveSpace([FilterKey.PeopleFilter]).rooms;
const peopleRooms = store
.getSortedRoomsInActiveSpace([FilterEnum.PeopleFilter])
.sections.flatMap((s) => s.rooms);
expect(peopleRooms).toHaveLength(2);
for (const i of [8, 27]) {
expect(peopleRooms).toContain(rooms[i]);
}
// Rest are normal rooms
const nonDms = store.getSortedRoomsInActiveSpace([FilterKey.RoomsFilter]).rooms;
const nonDms = store
.getSortedRoomsInActiveSpace([FilterEnum.RoomsFilter])
.sections.flatMap((s) => s.rooms);
expect(nonDms).toHaveLength(3);
for (const i of [6, 13, 75]) {
expect(nonDms).toContain(rooms[i]);
@ -680,7 +703,9 @@ describe("RoomListStoreV3", () => {
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
const result = store.getSortedRoomsInActiveSpace([FilterKey.InvitesFilter]).rooms;
const result = store
.getSortedRoomsInActiveSpace([FilterEnum.InvitesFilter])
.sections.flatMap((s) => s.rooms);
expect(result).toHaveLength(5);
for (const room of invitedRooms) {
expect(result).toContain(room);
@ -705,7 +730,9 @@ describe("RoomListStoreV3", () => {
await store.start();
// Should only give us rooms at index 8 and 27
const result = store.getSortedRoomsInActiveSpace([FilterKey.MentionsFilter]).rooms;
const result = store
.getSortedRoomsInActiveSpace([FilterEnum.MentionsFilter])
.sections.flatMap((s) => s.rooms);
expect(result).toHaveLength(2);
for (const i of [8, 27]) {
expect(result).toContain(rooms[i]);
@ -727,7 +754,9 @@ describe("RoomListStoreV3", () => {
await store.start();
// Sorted, filtered rooms should be 8, 27 and 75
const result = store.getSortedRoomsInActiveSpace([FilterKey.LowPriorityFilter]).rooms;
const result = store
.getSortedRoomsInActiveSpace([FilterEnum.LowPriorityFilter])
.sections.flatMap((s) => s.rooms);
expect(result).toHaveLength(3);
for (const i of [8, 27, 75]) {
expect(result).toContain(rooms[i]);
@ -755,10 +784,9 @@ describe("RoomListStoreV3", () => {
await store.start();
// Should give us only room at 8 since that's the only room which matches both filters
const result = store.getSortedRoomsInActiveSpace([
FilterKey.UnreadFilter,
FilterKey.FavouriteFilter,
]).rooms;
const result = store
.getSortedRoomsInActiveSpace([FilterEnum.UnreadFilter, FilterEnum.FavouriteFilter])
.sections.flatMap((s) => s.rooms);
expect(result).toHaveLength(1);
expect(result).toContain(rooms[8]);
});
@ -777,7 +805,9 @@ describe("RoomListStoreV3", () => {
},
true,
);
expect(store.getSortedRoomsInActiveSpace([FilterKey.InvitesFilter]).rooms).not.toContain(room);
expect(
store.getSortedRoomsInActiveSpace([FilterEnum.InvitesFilter]).sections.flatMap((s) => s.rooms),
).not.toContain(room);
room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Invite);
dispatcher.dispatch(
@ -789,11 +819,196 @@ describe("RoomListStoreV3", () => {
},
true,
);
expect(store.getSortedRoomsInActiveSpace([FilterKey.InvitesFilter]).rooms).toContain(room);
expect(
store.getSortedRoomsInActiveSpace([FilterEnum.InvitesFilter]).sections.flatMap((s) => s.rooms),
).toContain(room);
});
});
});
describe("Sections", () => {
function enableSections(): void {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
if (setting === "feature_room_list_sections") return true;
return false;
});
}
function findSection(sections: Section[], tag: string): Section | undefined {
return sections.find((s) => s.tag === tag);
}
function getClientAndRooms() {
const client = stubClient();
const rooms = getMockedRooms(client);
client.getVisibleRooms = jest.fn().mockReturnValue(rooms);
jest.spyOn(AsyncStoreWithClient.prototype, "matrixClient", "get").mockReturnValue(client);
return { client, rooms };
}
it("returns a single chats section when sections feature is disabled", async () => {
const { rooms } = getClientAndRooms();
// Mark some rooms as favourite so we can verify they are NOT split out
[0, 1, 2].forEach((i) => {
rooms[i].tags[DefaultTagID.Favourite] = {};
});
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
const result = store.getSortedRoomsInActiveSpace();
expect(result.sections).toHaveLength(1);
expect(result.sections[0].tag).toBe(CHATS_TAG);
// All rooms, including favourites, are in the single section
for (const i of [0, 1, 2]) {
expect(result.sections[0].rooms).toContain(rooms[i]);
}
});
it("returns three sections in the correct order when enabled", async () => {
enableSections();
getClientAndRooms();
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
const result = store.getSortedRoomsInActiveSpace();
expect(result.sections).toHaveLength(3);
expect(result.sections[0].tag).toBe(DefaultTagID.Favourite);
expect(result.sections[1].tag).toBe(CHATS_TAG);
expect(result.sections[2].tag).toBe(DefaultTagID.LowPriority);
});
it.each([
{ tag: DefaultTagID.Favourite, label: "Favourite" },
{ tag: DefaultTagID.LowPriority, label: "LowPriority" },
])("places tagged rooms only in the $label section", async ({ tag }) => {
enableSections();
const { rooms } = getClientAndRooms();
// Mark rooms 3, 7 with the given tag
[3, 7].forEach((i) => {
rooms[i].tags[tag] = {};
});
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
const { sections } = store.getSortedRoomsInActiveSpace();
const targetSection = findSection(sections, tag)!;
const chatsSection = findSection(sections, CHATS_TAG)!;
for (const i of [3, 7]) {
expect(targetSection.rooms).toContain(rooms[i]);
expect(chatsSection.rooms).not.toContain(rooms[i]);
}
});
it("places regular rooms only in the Chats section", async () => {
enableSections();
const { rooms } = getClientAndRooms();
// Mark some rooms as favourite / low priority so the rest are regular
rooms[0].tags[DefaultTagID.Favourite] = {};
rooms[1].tags[DefaultTagID.LowPriority] = {};
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
const { sections } = store.getSortedRoomsInActiveSpace();
const favSection = findSection(sections, DefaultTagID.Favourite)!;
const chatsSection = findSection(sections, CHATS_TAG)!;
const lowPrioritySection = findSection(sections, DefaultTagID.LowPriority)!;
// A regular room (index 5) should be in chats only
expect(chatsSection.rooms).toContain(rooms[5]);
expect(favSection.rooms).not.toContain(rooms[5]);
expect(lowPrioritySection.rooms).not.toContain(rooms[5]);
});
it("all rooms are accounted for across all sections", async () => {
enableSections();
const { rooms } = getClientAndRooms();
[2, 5].forEach((i) => {
rooms[i].tags[DefaultTagID.Favourite] = {};
});
[11].forEach((i) => {
rooms[i].tags[DefaultTagID.LowPriority] = {};
});
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
const { sections } = store.getSortedRoomsInActiveSpace();
const totalRooms = sections.flatMap((s) => s.rooms).length;
// All 100 rooms should be distributed across the three sections
expect(totalRooms).toBe(rooms.length);
});
it("applies additional filter keys within each section", async () => {
enableSections();
const { rooms } = getClientAndRooms();
// Rooms 3 and 7 are favourites; room 7 is also unread
[3, 7].forEach((i) => {
rooms[i].tags[DefaultTagID.Favourite] = {};
});
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockImplementation((room) => {
const state = {
hasUnreadCount: room === rooms[7],
} as unknown as RoomNotificationState;
return state;
});
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
const { sections } = store.getSortedRoomsInActiveSpace([FilterEnum.UnreadFilter]);
const favSection = findSection(sections, DefaultTagID.Favourite)!;
// Only room 7 is both favourite AND unread
expect(favSection.rooms).toHaveLength(1);
expect(favSection.rooms).toContain(rooms[7]);
});
it("sections respect space filtering", async () => {
enableSections();
const { rooms } = getClientAndRooms();
// Room 3 is a favourite room in the space
rooms[3].tags[DefaultTagID.Favourite] = {};
const spaceRoomId = "!space1:matrix.org";
const inSpaceIds = [3, 10, 20].map((i) => rooms[i].roomId);
jest.spyOn(SpaceStore.instance, "isRoomInSpace").mockImplementation((space, id) => {
if (space === spaceRoomId && inSpaceIds.includes(id)) return true;
return false;
});
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => spaceRoomId);
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
const { sections, spaceId } = store.getSortedRoomsInActiveSpace();
expect(spaceId).toBe(spaceRoomId);
const allRooms = sections.flatMap((s) => s.rooms);
const allRoomIds = allRooms.map((r) => r.roomId);
// Only rooms in the space should appear
for (const id of inSpaceIds) {
expect(allRoomIds).toContain(id);
}
// Rooms not in the space should not appear
expect(allRoomIds).not.toContain(rooms[50].roomId);
// Room 3 should be in the Favourite section specifically
const favSection = findSection(sections, DefaultTagID.Favourite)!;
expect(favSection.rooms).toContain(rooms[3]);
});
});
describe("Muted rooms", () => {
async function getRoomListStoreWithMutedRooms() {
const client = stubClient();

View File

@ -134,6 +134,7 @@ describe("ElementWidgetDriver", () => {
"org.matrix.msc4157.update_delayed_event",
"org.matrix.msc4407.send.sticky_event",
"org.matrix.msc4407.receive.sticky_event",
"org.matrix.msc4039.download_file",
// RTC decline events (send/receive, unstable/stable)
"org.matrix.msc2762.send.event:org.matrix.msc4310.rtc.decline",
"org.matrix.msc2762.send.event:m.rtc.decline",

View File

@ -0,0 +1,82 @@
/*
* 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 } from "@element-hq/web-shared-components";
import { EditHistoryActionBarViewModel } from "../../../src/viewmodels/message-body/EditHistoryActionBarViewModel";
describe("EditHistoryActionBarViewModel", () => {
it("builds a label snapshot with remove and view source actions", () => {
const vm = new EditHistoryActionBarViewModel({
canRemove: true,
showViewSource: true,
});
expect(vm.getSnapshot()).toMatchObject({
actions: [ActionBarAction.Remove, ActionBarAction.ViewSource],
presentation: "label",
isDownloadEncrypted: false,
isDownloadLoading: false,
isPinned: false,
isQuoteExpanded: false,
isThreadReplyAllowed: true,
});
});
it("omits actions that are disabled by props", () => {
const vm = new EditHistoryActionBarViewModel({
canRemove: false,
showViewSource: false,
});
expect(vm.getSnapshot().actions).toEqual([]);
});
it("updates the snapshot when props change", () => {
const vm = new EditHistoryActionBarViewModel({
canRemove: false,
showViewSource: true,
});
expect(vm.getSnapshot().actions).toEqual([ActionBarAction.ViewSource]);
vm.setProps({
canRemove: true,
showViewSource: false,
});
expect(vm.getSnapshot().actions).toEqual([ActionBarAction.Remove]);
});
it("forwards remove clicks to props", () => {
const onRemoveClick = jest.fn();
const vm = new EditHistoryActionBarViewModel({
canRemove: true,
showViewSource: false,
onRemoveClick,
});
const anchor = document.createElement("button");
vm.onRemoveClick(anchor);
expect(onRemoveClick).toHaveBeenCalledWith(anchor);
});
it("forwards view source clicks to props", () => {
const onViewSourceClick = jest.fn();
const vm = new EditHistoryActionBarViewModel({
canRemove: false,
showViewSource: true,
onViewSourceClick,
});
const anchor = document.createElement("button");
vm.onViewSourceClick(anchor);
expect(onViewSourceClick).toHaveBeenCalledWith(anchor);
});
});

View File

@ -0,0 +1,464 @@
/*
* 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 { EventType, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { VideoBodyViewState } from "@element-hq/web-shared-components";
import { decode } from "blurhash";
import { type Media } from "@element-hq/element-web-module-api";
import SettingsStore from "../../../src/settings/SettingsStore";
import { ImageSize } from "../../../src/settings/enums/ImageSize";
import { mediaFromContent } from "../../../src/customisations/Media";
import { BLURHASH_FIELD } from "../../../src/utils/image-media";
import { type MediaEventHelper } from "../../../src/utils/MediaEventHelper";
import { VideoBodyViewModel } from "../../../src/viewmodels/message-body/VideoBodyViewModel";
jest.mock("../../../src/customisations/Media", () => ({
mediaFromContent: jest.fn(),
}));
jest.mock("blurhash", () => ({
decode: jest.fn(),
}));
describe("VideoBodyViewModel", () => {
const mockedMediaFromContent = jest.mocked(mediaFromContent);
const mockedDecode = jest.mocked(decode);
const videoRef = { current: null };
let imageSizeWatcher: ((...args: [unknown, unknown, unknown, unknown, ImageSize]) => void) | undefined;
const flushPromises = async (): Promise<void> => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
};
const createEvent = ({
body = "demo video",
content = {},
}: {
body?: string;
content?: Record<string, unknown>;
} = {}): MatrixEvent => {
const { info: infoOverride, ...restContent } = content;
return new MatrixEvent({
type: EventType.RoomMessage,
room_id: "!room:server",
event_id: "$video:server",
sender: "@alice:server",
content: {
msgtype: "m.video",
body,
url: "https://server/video.mp4",
...restContent,
info: {
w: 320,
h: 180,
mimetype: "video/mp4",
...(infoOverride as Record<string, unknown> | undefined),
},
},
});
};
const createMediaEventHelper = ({
encrypted,
thumbnailUrl = "blob:thumbnail",
sourceUrl = "blob:video",
sourceBlob = new Blob(["video"], { type: "video/mp4" }),
}: {
encrypted: boolean;
thumbnailUrl?: string | null | Promise<string | null>;
sourceUrl?: string | null | Promise<string | null>;
sourceBlob?: Blob | Promise<Blob>;
}): MediaEventHelper =>
({
media: { isEncrypted: encrypted },
thumbnailUrl: { value: Promise.resolve(thumbnailUrl) },
sourceUrl: { value: Promise.resolve(sourceUrl) },
sourceBlob: { value: Promise.resolve(sourceBlob) },
}) as unknown as MediaEventHelper;
const createVm = (overrides?: Partial<ConstructorParameters<typeof VideoBodyViewModel>[0]>): VideoBodyViewModel =>
new VideoBodyViewModel({
mxEvent: createEvent(),
mediaVisible: false,
videoRef,
...overrides,
});
const createMockMedia = (content: Record<string, any>): Media =>
({
isEncrypted: !!content.file,
srcMxc: content.url ?? "mxc://server/video",
thumbnailMxc: content.info?.thumbnail_url ?? undefined,
srcHttp: content.url ?? "https://server/video.mp4",
thumbnailHttp:
content.info?.thumbnail_url === null
? null
: (content.info?.thumbnail_url ?? "https://server/poster.jpg"),
hasThumbnail: content.info?.thumbnail_url !== null,
getThumbnailHttp: jest.fn(),
getThumbnailOfSourceHttp: jest.fn(),
getSquareThumbnailHttp: jest.fn(),
downloadSource: jest.fn(),
}) as unknown as Media;
beforeEach(() => {
const originalGetValue = SettingsStore.getValue;
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting, ...args) => {
if (setting === "Images.size") {
return ImageSize.Normal;
}
if (setting === "autoplayVideo") {
return false;
}
return originalGetValue(setting, ...args);
});
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((_name, _roomId, callback) => {
imageSizeWatcher = callback as (...args: [unknown, unknown, unknown, unknown, ImageSize]) => void;
return "video-body-test-watch";
});
jest.spyOn(SettingsStore, "unwatchSetting").mockImplementation(jest.fn());
mockedMediaFromContent.mockImplementation((content) => createMockMedia(content));
mockedDecode.mockReturnValue(new Uint8ClampedArray(320 * 180 * 4));
});
afterEach(() => {
jest.restoreAllMocks();
imageSizeWatcher = undefined;
});
it("computes the initial hidden snapshot from props", () => {
const vm = createVm();
expect(vm.getSnapshot().state).toBe(VideoBodyViewState.HIDDEN);
expect(vm.getSnapshot().hiddenButtonLabel).toBeTruthy();
expect(vm.getSnapshot().maxWidth).toBe(320);
expect(vm.getSnapshot().maxHeight).toBe(180);
});
it("updates to ready when media becomes visible", () => {
const vm = createVm();
vm.setMediaVisible(true);
expect(vm.getSnapshot().state).toBe(VideoBodyViewState.READY);
expect(vm.getSnapshot().src).toBe("https://server/video.mp4");
expect(vm.getSnapshot().poster).toBe("https://server/poster.jpg");
});
it("uses the export urls directly when rendering for export", () => {
const vm = createVm({
mxEvent: createEvent({
content: {
url: "https://server/fallback.mp4",
file: {
url: "mxc://server/export-video",
},
},
}),
mediaVisible: true,
forExport: true,
});
vm.loadInitialMediaIfVisible();
expect(vm.getSnapshot()).toMatchObject({
state: VideoBodyViewState.READY,
src: "mxc://server/export-video",
preload: "metadata",
poster: undefined,
});
});
it("updates controls and autoplay flags when interaction is inhibited", () => {
const vm = createVm({ mediaVisible: true });
vm.setInhibitInteraction(true);
expect(vm.getSnapshot().controls).toBe(false);
expect(vm.getSnapshot().muted).toBe(false);
expect(vm.getSnapshot().autoPlay).toBe(false);
});
it("forwards preview clicks", () => {
const onPreviewClick = jest.fn();
const vm = createVm({ onPreviewClick });
vm.onPreviewClick();
expect(onPreviewClick).toHaveBeenCalledTimes(1);
});
it("preloads encrypted video when autoplay is enabled", async () => {
const originalGetValue = SettingsStore.getValue;
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting, ...args) => {
if (setting === "Images.size") return ImageSize.Normal;
if (setting === "autoplayVideo") return true;
return originalGetValue(setting, ...args);
});
const vm = createVm({
mxEvent: createEvent({
content: {
file: { url: "mxc://server/encrypted-video" },
},
}),
mediaEventHelper: createMediaEventHelper({
encrypted: true,
thumbnailUrl: "blob:encrypted-poster",
sourceUrl: "blob:encrypted-video",
}),
mediaVisible: true,
});
vm.loadInitialMediaIfVisible();
expect(vm.getSnapshot().state).toBe(VideoBodyViewState.LOADING);
await flushPromises();
expect(vm.getSnapshot()).toMatchObject({
state: VideoBodyViewState.READY,
src: "blob:encrypted-video",
poster: "blob:encrypted-poster",
muted: true,
autoPlay: true,
});
});
it("keeps encrypted video lazy-loadable when autoplay is disabled", async () => {
const vm = createVm({
mxEvent: createEvent({
content: {
file: { url: "mxc://server/encrypted-video" },
info: {
mimetype: "video/quicktime",
},
},
}),
mediaEventHelper: createMediaEventHelper({
encrypted: true,
thumbnailUrl: null,
}),
mediaVisible: true,
});
vm.loadInitialMediaIfVisible();
await flushPromises();
expect(vm.getSnapshot()).toMatchObject({
state: VideoBodyViewState.READY,
src: "data:video/mp4,",
poster: "data:video/mp4,",
preload: "none",
autoPlay: false,
});
});
it("switches to the error state when encrypted preload fails", async () => {
jest.spyOn(logger, "warn").mockImplementation(jest.fn());
const vm = createVm({
mxEvent: createEvent({
content: {
file: { url: "mxc://server/encrypted-video" },
},
}),
mediaEventHelper: createMediaEventHelper({
encrypted: true,
thumbnailUrl: Promise.reject(new Error("decrypt failed")),
}),
mediaVisible: true,
});
vm.loadInitialMediaIfVisible();
await flushPromises();
expect(vm.getSnapshot().state).toBe(VideoBodyViewState.ERROR);
expect(vm.getSnapshot().errorLabel).toBeTruthy();
});
it("loads the encrypted source on play when only a placeholder url is present", async () => {
const play = jest.fn();
const vm = createVm({
mxEvent: createEvent({
content: {
file: { url: "mxc://server/encrypted-video" },
},
}),
mediaEventHelper: createMediaEventHelper({
encrypted: true,
thumbnailUrl: null,
sourceUrl: "blob:played-video",
}),
mediaVisible: true,
videoRef: { current: { play } } as any,
});
vm.loadInitialMediaIfVisible();
await flushPromises();
await vm.onPlay();
expect(vm.getSnapshot().src).toBe("blob:played-video");
expect(play).toHaveBeenCalledTimes(1);
});
it("shows an error when play is requested without encrypted media data", async () => {
const vm = createVm({
mxEvent: createEvent({
content: {
file: { url: "mxc://server/encrypted-video" },
},
}),
mediaVisible: true,
});
vm.loadInitialMediaIfVisible();
await vm.onPlay();
expect(vm.getSnapshot().state).toBe(VideoBodyViewState.ERROR);
});
it("recomputes dimensions when the image-size setting changes", () => {
const vm = createVm({
mxEvent: createEvent({
content: {
info: {
w: 1280,
h: 720,
},
},
}),
mediaVisible: false,
});
expect(vm.getSnapshot().maxWidth).toBe(324);
expect(vm.getSnapshot().maxHeight).toBe(182);
imageSizeWatcher?.(undefined, undefined, undefined, undefined, ImageSize.Large);
expect(vm.getSnapshot().maxWidth).toBe(800);
expect(vm.getSnapshot().maxHeight).toBe(450);
});
it("uses the blurhash poster while the thumbnail image is loading", () => {
const originalCreateElement = document.createElement.bind(document);
const originalImage = global.Image;
let imageOnLoad: (() => void) | undefined;
const context = {
createImageData: jest.fn((width: number, height: number) => ({
data: new Uint8ClampedArray(width * height * 4),
})),
putImageData: jest.fn(),
};
const canvas = {
width: 0,
height: 0,
getContext: jest.fn(() => context),
toDataURL: jest.fn(() => "data:image/png;base64,blurhash"),
};
jest.spyOn(document, "createElement").mockImplementation(((tagName: string) => {
if (tagName === "canvas") {
return canvas as any;
}
return originalCreateElement(tagName);
}) as typeof document.createElement);
class MockImage {
public onload?: () => void;
public set src(_value: string) {
imageOnLoad = this.onload;
}
}
global.Image = MockImage as unknown as typeof Image;
const vm = createVm({
mxEvent: createEvent({
content: {
info: {
[BLURHASH_FIELD]: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
},
},
}),
mediaVisible: true,
});
vm.loadInitialMediaIfVisible();
expect(vm.getSnapshot().poster).toBe("data:image/png;base64,blurhash");
imageOnLoad?.();
expect(vm.getSnapshot().poster).toBe("https://server/poster.jpg");
global.Image = originalImage;
});
it("resets encrypted media state when the event changes", async () => {
const vm = createVm({
mxEvent: createEvent({
content: {
body: "first video",
file: { url: "mxc://server/video-a" },
},
}),
mediaEventHelper: createMediaEventHelper({
encrypted: true,
thumbnailUrl: null,
sourceUrl: "blob:first-video",
}),
mediaVisible: true,
});
vm.loadInitialMediaIfVisible();
await flushPromises();
expect(vm.getSnapshot().src).toBe("data:video/mp4,");
vm.setEvent(
createEvent({
body: "second video",
content: {
file: { url: "mxc://server/video-b" },
},
}),
createMediaEventHelper({
encrypted: true,
thumbnailUrl: null,
sourceUrl: "blob:second-video",
}),
);
await flushPromises();
expect(vm.getSnapshot().videoLabel).toBe("second video");
expect(vm.getSnapshot().src).toBe("data:video/mp4,");
});
it("does not emit for unchanged targeted setters", () => {
const event = createEvent();
const onPreviewClick = jest.fn();
const vm = createVm({
mxEvent: event,
mediaVisible: false,
onPreviewClick,
});
const listener = jest.fn();
vm.subscribe(listener);
vm.setEvent(event, undefined);
vm.setForExport(undefined);
vm.setInhibitInteraction(undefined);
vm.setMediaVisible(false);
vm.setOnPreviewClick(onPreviewClick);
expect(listener).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,48 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { RoomListSectionHeaderViewModel } from "../../../src/viewmodels/room-list/RoomListSectionHeaderViewModel";
describe("RoomListSectionHeaderViewModel", () => {
let onToggleExpanded: jest.Mock;
beforeEach(() => {
onToggleExpanded = jest.fn();
});
it("should initialize snapshot from props", () => {
const vm = new RoomListSectionHeaderViewModel({
tag: "m.favourite",
title: "Favourites",
onToggleExpanded,
});
const snapshot = vm.getSnapshot();
expect(snapshot.id).toBe("m.favourite");
expect(snapshot.title).toBe("Favourites");
expect(snapshot.isExpanded).toBe(true);
});
it("should toggle expanded state on click", () => {
const vm = new RoomListSectionHeaderViewModel({
tag: "m.favourite",
title: "Favourites",
onToggleExpanded,
});
expect(vm.isExpanded).toBe(true);
vm.onClick();
expect(vm.isExpanded).toBe(false);
expect(vm.getSnapshot().isExpanded).toBe(false);
expect(onToggleExpanded).toHaveBeenCalledWith(false);
vm.onClick();
expect(vm.isExpanded).toBe(true);
expect(vm.getSnapshot().isExpanded).toBe(true);
expect(onToggleExpanded).toHaveBeenCalledWith(true);
});
});

View File

@ -7,17 +7,20 @@
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { waitFor } from "jest-matrix-react";
import { createTestClient, flushPromises, mkStubRoom, stubClient } from "../../test-utils";
import RoomListStoreV3, { RoomListStoreV3Event } from "../../../src/stores/room-list-v3/RoomListStoreV3";
import RoomListStoreV3, { CHATS_TAG, RoomListStoreV3Event } from "../../../src/stores/room-list-v3/RoomListStoreV3";
import SpaceStore from "../../../src/stores/spaces/SpaceStore";
import { FilterKey } from "../../../src/stores/room-list-v3/skip-list/filters";
import { FilterEnum } from "../../../src/stores/room-list-v3/skip-list/filters";
import dispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import { SdkContextClass } from "../../../src/contexts/SDKContext";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { RoomListViewModel } from "../../../src/viewmodels/room-list/RoomListViewModel";
import { hasCreateRoomRights } from "../../../src/viewmodels/room-list/utils";
import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
import SettingsStore from "../../../src/settings/SettingsStore";
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
hasCreateRoomRights: jest.fn().mockReturnValue(false),
@ -46,7 +49,7 @@ describe("RoomListViewModel", () => {
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [room1, room2, room3],
sections: [{ tag: CHATS_TAG, rooms: [room1, room2, room3] }],
});
jest.spyOn(RoomListStoreV3.instance, "isLoadingRooms", "get").mockReturnValue(false);
@ -77,12 +80,12 @@ describe("RoomListViewModel", () => {
it("should initialize with empty room list", () => {
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [],
sections: [{ tag: CHATS_TAG, rooms: [] }],
});
viewModel = new RoomListViewModel({ client: matrixClient });
expect(viewModel.getSnapshot().sections[0].roomIds).toEqual([]);
expect(viewModel.getSnapshot().sections).toEqual([]);
expect(viewModel.getSnapshot().isRoomListEmpty).toBe(true);
});
@ -101,7 +104,7 @@ describe("RoomListViewModel", () => {
const newRoom = mkStubRoom("!room4:server", "Room 4", matrixClient);
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [room1, room2, room3, newRoom],
sections: [{ tag: CHATS_TAG, rooms: [room1, room2, room3, newRoom] }],
});
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
@ -136,7 +139,7 @@ describe("RoomListViewModel", () => {
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
// View model should be still valid
expect(room1VM.isDisposed).toBe(false);
expect(room1VM!.isDisposed).toBe(false);
});
});
@ -148,7 +151,7 @@ describe("RoomListViewModel", () => {
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "!space:server",
rooms: spaceRoomList,
sections: [{ tag: CHATS_TAG, rooms: spaceRoomList }],
});
jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockReturnValue("!room1:server");
@ -163,8 +166,8 @@ describe("RoomListViewModel", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
// Get view models for visible rooms
const vm1 = viewModel.getRoomItemViewModel("!room1:server");
const vm2 = viewModel.getRoomItemViewModel("!room2:server");
const vm1 = viewModel.getRoomItemViewModel("!room1:server")!;
const vm2 = viewModel.getRoomItemViewModel("!room2:server")!;
const disposeSpy1 = jest.spyOn(vm1, "dispose");
const disposeSpy2 = jest.spyOn(vm2, "dispose");
@ -172,7 +175,7 @@ describe("RoomListViewModel", () => {
// Change space
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "!space:server",
rooms: [room3],
sections: [{ tag: CHATS_TAG, rooms: [room3] }],
});
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
@ -188,7 +191,7 @@ describe("RoomListViewModel", () => {
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "!space:server",
rooms: [newSpaceRoom],
sections: [{ tag: CHATS_TAG, rooms: [newSpaceRoom] }],
});
jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockReturnValue(null);
@ -197,7 +200,7 @@ describe("RoomListViewModel", () => {
// New space room should be accessible
expect(() => viewModel.getRoomItemViewModel("!spaceroom:server")).not.toThrow();
// Old rooms from the home space should not be accessible
expect(() => viewModel.getRoomItemViewModel("!room1:server")).toThrow();
expect(viewModel.getRoomItemViewModel("!room1:server")).toBeUndefined();
});
});
@ -252,7 +255,7 @@ describe("RoomListViewModel", () => {
// Simulate room list update that would move room2 to front
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [room2, room1, room3], // room2 moved to front
sections: [{ tag: CHATS_TAG, rooms: [room2, room1, room3] }], // room2 moved to front
});
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
@ -295,8 +298,8 @@ describe("RoomListViewModel", () => {
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [room1],
filterKeys: [FilterKey.UnreadFilter],
sections: [{ tag: CHATS_TAG, rooms: [room1] }],
filterKeys: [FilterEnum.UnreadFilter],
});
viewModel.onToggleFilter("unread");
@ -311,8 +314,8 @@ describe("RoomListViewModel", () => {
// Turn filter on
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [room1],
filterKeys: [FilterKey.UnreadFilter],
sections: [{ tag: CHATS_TAG, rooms: [room1] }],
filterKeys: [FilterEnum.UnreadFilter],
});
viewModel.onToggleFilter("unread");
@ -321,7 +324,7 @@ describe("RoomListViewModel", () => {
// Turn filter off
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [room1, room2, room3],
sections: [{ tag: CHATS_TAG, rooms: [room1, room2, room3] }],
});
viewModel.onToggleFilter("unread");
@ -341,7 +344,7 @@ describe("RoomListViewModel", () => {
const itemViewModel = viewModel.getRoomItemViewModel("!room1:server");
expect(itemViewModel).toBeDefined();
expect(itemViewModel.getSnapshot().room).toBe(room1);
expect(itemViewModel!.getSnapshot().room).toBe(room1);
});
it("should reuse existing room item view model", () => {
@ -353,12 +356,10 @@ describe("RoomListViewModel", () => {
expect(itemViewModel1).toBe(itemViewModel2);
});
it("should throw error when requesting view model for non-existent room", () => {
it("should return undefined for non-existent room", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
expect(() => {
viewModel.getRoomItemViewModel("!nonexistent:server");
}).toThrow();
expect(viewModel.getRoomItemViewModel("!nonexistent:server")).toBeUndefined();
});
it("should not throw when requesting view model for a room removed from the list but still in roomsMap", () => {
@ -367,7 +368,7 @@ describe("RoomListViewModel", () => {
// Normal list update removes room2 from the list
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [room1, room3],
sections: [{ tag: CHATS_TAG, rooms: [room1, room3] }],
});
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
@ -375,7 +376,7 @@ describe("RoomListViewModel", () => {
expect(() => viewModel.getRoomItemViewModel("!room2:server")).not.toThrow();
});
it("should throw when requesting view model for a room from old space after space change", () => {
it("should return undefined for a room from old space after space change", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
const spaceRoom = mkStubRoom("!newroom:server", "New Room", matrixClient);
@ -383,15 +384,13 @@ describe("RoomListViewModel", () => {
// Space change: new space only has spaceRoom
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "!space:server",
rooms: [spaceRoom],
sections: [{ tag: CHATS_TAG, rooms: [spaceRoom] }],
});
jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockReturnValue(null);
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
expect(() => viewModel.getRoomItemViewModel("!room1:server")).toThrow(
"Room !room1:server not found in roomsMap",
);
expect(viewModel.getRoomItemViewModel("!room1:server")).toBeUndefined();
});
it("should recover when roomsMap is stale but roomsResult has the room", () => {
@ -407,9 +406,9 @@ describe("RoomListViewModel", () => {
it("should dispose view models for rooms no longer visible", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
const vm1 = viewModel.getRoomItemViewModel("!room1:server");
const vm2 = viewModel.getRoomItemViewModel("!room2:server");
const vm3 = viewModel.getRoomItemViewModel("!room3:server");
const vm1 = viewModel.getRoomItemViewModel("!room1:server")!;
const vm2 = viewModel.getRoomItemViewModel("!room2:server")!;
const vm3 = viewModel.getRoomItemViewModel("!room3:server")!;
const disposeSpy1 = jest.spyOn(vm1, "dispose");
const disposeSpy3 = jest.spyOn(vm3, "dispose");
@ -593,8 +592,8 @@ describe("RoomListViewModel", () => {
it("should dispose all room item view models on dispose", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
const vm1 = viewModel.getRoomItemViewModel("!room1:server");
const vm2 = viewModel.getRoomItemViewModel("!room2:server");
const vm1 = viewModel.getRoomItemViewModel("!room1:server")!;
const vm2 = viewModel.getRoomItemViewModel("!room2:server")!;
const disposeSpy1 = jest.spyOn(vm1, "dispose");
const disposeSpy2 = jest.spyOn(vm2, "dispose");
@ -604,5 +603,297 @@ describe("RoomListViewModel", () => {
expect(disposeSpy1).toHaveBeenCalled();
expect(disposeSpy2).toHaveBeenCalled();
});
describe("Sections (feature_room_list_sections)", () => {
let favRoom1: Room;
let favRoom2: Room;
let lowPriorityRoom: Room;
let regularRoom1: Room;
let regularRoom2: Room;
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
if (setting === "feature_room_list_sections") return true;
return false;
});
favRoom1 = mkStubRoom("!fav1:server", "Fav 1", matrixClient);
favRoom2 = mkStubRoom("!fav2:server", "Fav 2", matrixClient);
lowPriorityRoom = mkStubRoom("!low1:server", "Low 1", matrixClient);
regularRoom1 = mkStubRoom("!reg1:server", "Reg 1", matrixClient);
regularRoom2 = mkStubRoom("!reg2:server", "Reg 2", matrixClient);
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
sections: [
{ tag: DefaultTagID.Favourite, rooms: [favRoom1, favRoom2] },
{ tag: CHATS_TAG, rooms: [regularRoom1, regularRoom2] },
{ tag: DefaultTagID.LowPriority, rooms: [lowPriorityRoom] },
],
});
});
it("should initialize with multiple sections", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
const snapshot = viewModel.getSnapshot();
expect(snapshot.sections).toHaveLength(3);
expect(snapshot.sections[0].id).toBe(DefaultTagID.Favourite);
expect(snapshot.sections[0].roomIds).toEqual(["!fav1:server", "!fav2:server"]);
expect(snapshot.sections[1].id).toBe(CHATS_TAG);
expect(snapshot.sections[1].roomIds).toEqual(["!reg1:server", "!reg2:server"]);
expect(snapshot.sections[2].id).toBe(DefaultTagID.LowPriority);
expect(snapshot.sections[2].roomIds).toEqual(["!low1:server"]);
});
it("should not be a flat list when multiple sections exist", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
expect(viewModel.getSnapshot().isFlatList).toBe(false);
});
it("should be a flat list when only chats section has rooms", () => {
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
sections: [
{ tag: DefaultTagID.Favourite, rooms: [] },
{ tag: CHATS_TAG, rooms: [regularRoom1] },
{ tag: DefaultTagID.LowPriority, rooms: [] },
],
});
viewModel = new RoomListViewModel({ client: matrixClient });
expect(viewModel.getSnapshot().isFlatList).toBe(true);
expect(viewModel.getSnapshot().sections).toHaveLength(1);
expect(viewModel.getSnapshot().sections[0].id).toBe(CHATS_TAG);
});
it("should exclude favourite and low_priority from filter list", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
const snapshot = viewModel.getSnapshot();
expect(snapshot.filterIds).not.toContain("favourite");
expect(snapshot.filterIds).not.toContain("low_priority");
// Other filters should still be present
expect(snapshot.filterIds).toContain("unread");
expect(snapshot.filterIds).toContain("people");
});
it("should omit empty sections from snapshot", () => {
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
sections: [
{ tag: DefaultTagID.Favourite, rooms: [] },
{ tag: CHATS_TAG, rooms: [regularRoom1] },
{ tag: DefaultTagID.LowPriority, rooms: [] },
],
});
viewModel = new RoomListViewModel({ client: matrixClient });
const snapshot = viewModel.getSnapshot();
expect(snapshot.sections).toHaveLength(1);
expect(snapshot.sections[0].id).toBe(CHATS_TAG);
});
it("should create section header view models on demand", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
const headerVM = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
expect(headerVM).toBeDefined();
expect(headerVM.getSnapshot().id).toBe(DefaultTagID.Favourite);
expect(headerVM.getSnapshot().isExpanded).toBe(true);
});
it("should reuse section header view models", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
const headerVM1 = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
const headerVM2 = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
expect(headerVM1).toBe(headerVM2);
});
it("should hide room IDs when a section is collapsed", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
// Collapse the favourite section
const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
favHeader.onClick();
expect(favHeader.isExpanded).toBe(false);
const snapshot = viewModel.getSnapshot();
const favSection = snapshot.sections.find((s) => s.id === DefaultTagID.Favourite);
expect(favSection).toBeDefined();
// Collapsed sections have an empty roomIds list
expect(favSection!.roomIds).toEqual([]);
// Other sections remain unaffected
const chatsSection = snapshot.sections.find((s) => s.id === CHATS_TAG);
expect(chatsSection!.roomIds).toEqual(["!reg1:server", "!reg2:server"]);
});
it("should compute activeRoomIndex relative to visible rooms when a section is collapsed", async () => {
viewModel = new RoomListViewModel({ client: matrixClient });
// Collapse the favourite section (which has 2 rooms: fav1, fav2)
const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
favHeader.onClick();
expect(favHeader.isExpanded).toBe(false);
// Select regularRoom1, which is the first room in the chats section
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!reg1:server");
dispatcher.dispatch({
action: Action.ActiveRoomChanged,
newRoomId: "!reg1:server",
});
await waitFor(() => {
const snapshot = viewModel.getSnapshot();
// The favourite section is collapsed so its 2 rooms are not visible.
// regularRoom1 should be at index 0 in the visible list, not index 2.
expect(snapshot.roomListState.activeRoomIndex).toBe(0);
});
});
it("should restore room IDs when a section is re-expanded", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
// Collapse then re-expand
favHeader.onClick();
favHeader.onClick();
expect(favHeader.isExpanded).toBe(true);
const snapshot = viewModel.getSnapshot();
const favSection = snapshot.sections.find((s) => s.id === DefaultTagID.Favourite);
expect(favSection!.roomIds).toEqual(["!fav1:server", "!fav2:server"]);
});
it("should update sections when room list changes", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
const newFav = mkStubRoom("!fav3:server", "Fav 3", matrixClient);
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
sections: [
{ tag: DefaultTagID.Favourite, rooms: [favRoom1, favRoom2, newFav] },
{ tag: CHATS_TAG, rooms: [regularRoom1, regularRoom2] },
{ tag: DefaultTagID.LowPriority, rooms: [lowPriorityRoom] },
],
});
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
const snapshot = viewModel.getSnapshot();
expect(snapshot.sections[0].roomIds).toEqual(["!fav1:server", "!fav2:server", "!fav3:server"]);
});
it("should preserve section collapse state across list updates", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
// Collapse favourites
const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
favHeader.onClick();
// Trigger a list update
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
const snapshot = viewModel.getSnapshot();
const favSection = snapshot.sections.find((s) => s.id === DefaultTagID.Favourite);
expect(favSection!.roomIds).toEqual([]);
});
it("should preserve section collapse state across space changes", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
// Collapse favourites
const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
favHeader.onClick();
// Switch to a different space with its own rooms
const spaceFav = mkStubRoom("!spacefav:server", "Space Fav", matrixClient);
const spaceReg = mkStubRoom("!spacereg:server", "Space Reg", matrixClient);
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "!space:server",
sections: [
{ tag: DefaultTagID.Favourite, rooms: [spaceFav] },
{ tag: CHATS_TAG, rooms: [spaceReg] },
{ tag: DefaultTagID.LowPriority, rooms: [] },
],
});
jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockReturnValue(null);
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
const snapshot = viewModel.getSnapshot();
// Favourites should still be collapsed even after the space change
const favSection = snapshot.sections.find((s) => s.id === DefaultTagID.Favourite);
expect(favSection).toBeDefined();
expect(favSection!.roomIds).toEqual([]);
// Other sections should remain expanded
const chatsSection = snapshot.sections.find((s) => s.id === CHATS_TAG);
expect(chatsSection!.roomIds).toEqual(["!spacereg:server"]);
});
it("should apply filters across all sections", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
// Only favRoom1 is unread
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
sections: [
{ tag: DefaultTagID.Favourite, rooms: [favRoom1] },
{ tag: CHATS_TAG, rooms: [] },
{ tag: DefaultTagID.LowPriority, rooms: [] },
],
filterKeys: [FilterEnum.UnreadFilter],
});
viewModel.onToggleFilter("unread");
const snapshot = viewModel.getSnapshot();
expect(snapshot.activeFilterId).toBe("unread");
// Only the favourite section should remain (chats and low priority are empty)
expect(snapshot.sections).toHaveLength(1);
expect(snapshot.sections[0].id).toBe(DefaultTagID.Favourite);
expect(snapshot.sections[0].roomIds).toEqual(["!fav1:server"]);
});
it("should apply sticky room within the correct section", async () => {
stubClient();
viewModel = new RoomListViewModel({ client: matrixClient });
// Select favRoom1 (index 0 globally, index 0 in favourites section)
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!fav1:server");
dispatcher.dispatch({
action: Action.ActiveRoomChanged,
newRoomId: "!fav1:server",
});
await flushPromises();
expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(0);
// Room list update moves favRoom1 to second position within favourites
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
sections: [
{ tag: DefaultTagID.Favourite, rooms: [favRoom2, favRoom1] },
{ tag: CHATS_TAG, rooms: [regularRoom1, regularRoom2] },
{ tag: DefaultTagID.LowPriority, rooms: [lowPriorityRoom] },
],
});
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
// Sticky room should keep favRoom1 at index 0 within the favourites section
const snapshot = viewModel.getSnapshot();
expect(snapshot.sections[0].roomIds[0]).toBe("!fav1:server");
expect(snapshot.roomListState.activeRoomIndex).toBe(0);
});
});
});
});

View File

@ -0,0 +1,716 @@
/*
* 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 EventEmitter from "events";
import { waitFor } from "@testing-library/dom";
import { mocked } from "jest-mock";
import {
EventStatus,
EventTimeline,
EventType,
M_BEACON_INFO,
MatrixEvent,
MatrixEventEvent,
MsgType,
RelationType,
RoomStateEvent,
} from "matrix-js-sdk/src/matrix";
import { ActionBarAction } from "@element-hq/web-shared-components";
import {
EventTileActionBarViewModel,
type EventTileActionBarViewModelProps,
} from "../../../src/viewmodels/room/EventTileActionBarViewModel";
import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import Resend from "../../../src/Resend";
import PinningUtils from "../../../src/utils/PinningUtils";
import PosthogTrackers from "../../../src/PosthogTrackers";
import Modal from "../../../src/Modal";
import ErrorDialog from "../../../src/components/views/dialogs/ErrorDialog";
import SettingsStore from "../../../src/settings/SettingsStore";
import { ModuleApi } from "../../../src/modules/Api";
import { canCancel, canEditContent, editEvent, isContentActionable } from "../../../src/utils/EventUtils";
import { shouldDisplayReply } from "../../../src/utils/Reply";
import { MediaEventHelper } from "../../../src/utils/MediaEventHelper";
import { getMediaVisibility, setMediaVisibility } from "../../../src/utils/media/mediaVisibility";
import { createTestClient } from "../../test-utils";
jest.mock("../../../src/dispatcher/dispatcher", () => ({
__esModule: true,
default: {
dispatch: jest.fn(),
register: jest.fn().mockReturnValue("dispatcher-ref"),
unregister: jest.fn(),
},
}));
jest.mock("../../../src/Resend", () => ({
__esModule: true,
default: {
resend: jest.fn(),
removeFromQueue: jest.fn(),
},
}));
jest.mock("../../../src/PosthogTrackers", () => ({
__esModule: true,
default: {
trackPinUnpinMessage: jest.fn(),
},
}));
jest.mock("../../../src/Modal", () => ({
__esModule: true,
default: {
createDialog: jest.fn(),
},
}));
jest.mock("../../../src/languageHandler", () => ({
_t: (key: string) => {
switch (key) {
case "timeline|download_failed":
return "Download failed";
case "timeline|download_failed_description":
return "Failed to download file";
case "common|image":
return "Image";
default:
return key;
}
},
_td: (key: string) => key,
}));
jest.mock("../../../src/utils/EventUtils", () => ({
canCancel: jest.fn(),
canEditContent: jest.fn(),
editEvent: jest.fn(),
isContentActionable: jest.fn(),
}));
jest.mock("../../../src/utils/PinningUtils", () => ({
__esModule: true,
default: {
canPin: jest.fn(),
canUnpin: jest.fn(),
isPinned: jest.fn(),
pinOrUnpinEvent: jest.fn(),
},
}));
jest.mock("../../../src/utils/Reply", () => ({
shouldDisplayReply: jest.fn(),
}));
jest.mock("../../../src/utils/media/mediaVisibility", () => ({
getMediaVisibility: jest.fn(),
setMediaVisibility: jest.fn(),
}));
const mockDownload = jest.fn();
jest.mock("../../../src/utils/FileDownloader", () => ({
FileDownloader: jest.fn().mockImplementation(() => ({
download: mockDownload,
})),
}));
describe("EventTileActionBarViewModel", () => {
const userId = "@alice:example.org";
const roomId = "!room:example.org";
const rootEvent = new MatrixEvent({
type: EventType.RoomMessage,
room_id: roomId,
sender: "@root:example.org",
event_id: "$root",
content: { msgtype: MsgType.Text, body: "Root" },
});
let client: ReturnType<typeof createTestClient>;
let roomState: EventEmitter;
let room: {
getLiveTimeline: jest.Mock;
};
let getHintsForMessageSpy: jest.SpyInstance;
const createMessageEvent = (overrides: Partial<ConstructorParameters<typeof MatrixEvent>[0]> = {}): MatrixEvent =>
new MatrixEvent({
type: EventType.RoomMessage,
room_id: roomId,
sender: userId,
event_id: "$event",
content: { msgtype: MsgType.Text, body: "Hello" },
...overrides,
});
const createVm = (props: Partial<EventTileActionBarViewModelProps> = {}): EventTileActionBarViewModel => {
const mxEvent = props.mxEvent ?? createMessageEvent();
return new EventTileActionBarViewModel({
mxEvent,
timelineRenderingType: TimelineRenderingType.Room,
canSendMessages: true,
canReact: true,
...props,
});
};
const createPendingPromise = <T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
} => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
beforeEach(() => {
jest.clearAllMocks();
client = createTestClient();
roomState = new EventEmitter();
room = {
getLiveTimeline: jest.fn().mockReturnValue({
getState: jest
.fn()
.mockImplementation((dir) => (dir === EventTimeline.FORWARDS ? roomState : undefined)),
}),
};
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client);
jest.spyOn(client, "getRoom").mockReturnValue(room as never);
jest.spyOn(client, "decryptEventIfNeeded");
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((name, scope) => `${name}:${scope ?? "global"}`);
jest.spyOn(SettingsStore, "unwatchSetting").mockImplementation(() => {});
mocked(canCancel).mockImplementation((status) => status === EventStatus.QUEUED);
mocked(canEditContent).mockReturnValue(true);
mocked(isContentActionable).mockReturnValue(true);
mocked(shouldDisplayReply).mockReturnValue(true);
mocked(getMediaVisibility).mockReturnValue(true);
mocked(setMediaVisibility).mockResolvedValue(undefined);
mocked(PinningUtils.canPin).mockReturnValue(false);
mocked(PinningUtils.canUnpin).mockReturnValue(false);
mocked(PinningUtils.isPinned).mockReturnValue(false);
mocked(PinningUtils.pinOrUnpinEvent).mockResolvedValue(undefined);
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(false);
jest.spyOn(MediaEventHelper, "canHide").mockReturnValue(false);
mockDownload.mockResolvedValue(undefined);
getHintsForMessageSpy = jest.spyOn(ModuleApi.instance.customComponents, "getHintsForMessage");
getHintsForMessageSpy.mockReturnValue(null);
});
afterEach(() => {
getHintsForMessageSpy.mockRestore();
jest.restoreAllMocks();
});
it("builds the snapshot for an actionable message", async () => {
const vm = createVm({ isQuoteExpanded: true });
await waitFor(() =>
expect(vm.getSnapshot()).toMatchObject({
actions: [
ActionBarAction.React,
ActionBarAction.Reply,
ActionBarAction.ReplyInThread,
ActionBarAction.Edit,
ActionBarAction.Expand,
ActionBarAction.Options,
],
presentation: "icon",
isDownloadEncrypted: false,
isDownloadLoading: false,
isPinned: false,
isQuoteExpanded: true,
isThreadReplyAllowed: true,
}),
);
});
it("reacts to media download permission hints and room state updates", async () => {
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(true);
jest.spyOn(MediaEventHelper, "canHide").mockReturnValue(true);
getHintsForMessageSpy.mockReturnValue({
allowDownloadingMedia: jest.fn().mockResolvedValue(true),
} as never);
const vm = createVm({
mxEvent: createMessageEvent({
content: { msgtype: MsgType.Image, body: "Image", url: "mxc://example.org/file" },
}),
});
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
expect(vm.getSnapshot().actions).toContain(ActionBarAction.Hide);
await waitFor(() => expect(vm.getSnapshot().actions).toContain(ActionBarAction.Download));
mocked(PinningUtils.isPinned).mockReturnValue(true);
roomState.emit(
RoomStateEvent.Events,
new MatrixEvent({
type: EventType.RoomPinnedEvents,
room_id: roomId,
sender: userId,
content: { pinned: ["$event"] },
}),
);
expect(vm.getSnapshot().isPinned).toBe(true);
mocked(getMediaVisibility).mockReturnValue(false);
roomState.emit(
RoomStateEvent.Events,
new MatrixEvent({
type: EventType.RoomJoinRules,
room_id: roomId,
sender: userId,
content: { join_rule: "public" },
}),
);
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Hide);
});
it("ignores stale download permission results after setProps changes the event", async () => {
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(true);
const permissionA = createPendingPromise<boolean>();
const permissionB = createPendingPromise<boolean>();
const eventA = createMessageEvent({
event_id: "$eventA",
content: { msgtype: MsgType.Image, body: "Image A", url: "mxc://example.org/a" },
});
const eventB = createMessageEvent({
event_id: "$eventB",
content: { msgtype: MsgType.Image, body: "Image B", url: "mxc://example.org/b" },
});
getHintsForMessageSpy.mockImplementation((event) => {
if (event === eventA) {
return {
allowDownloadingMedia: jest.fn().mockReturnValue(permissionA.promise),
} as never;
}
if (event === eventB) {
return {
allowDownloadingMedia: jest.fn().mockReturnValue(permissionB.promise),
} as never;
}
return null;
});
const vm = createVm({ mxEvent: eventA });
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
vm.setProps({ mxEvent: eventB });
permissionA.resolve(true);
await Promise.resolve();
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
permissionB.resolve(false);
await Promise.resolve();
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
});
it("refreshes on event status changes and removes listeners on dispose", () => {
const mxEvent = createMessageEvent();
const offSpy = jest.spyOn(mxEvent, "off");
const roomStateOffSpy = jest.spyOn(roomState, "off");
const vm = createVm({ mxEvent });
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Cancel);
mxEvent.setStatus(EventStatus.QUEUED);
expect(vm.getSnapshot().actions).toContain(ActionBarAction.Cancel);
expect(client.decryptEventIfNeeded).toHaveBeenCalledWith(mxEvent);
vm.dispose();
expect(offSpy).toHaveBeenCalledWith(MatrixEventEvent.Status, expect.any(Function));
expect(offSpy).toHaveBeenCalledWith(MatrixEventEvent.Decrypted, expect.any(Function));
expect(offSpy).toHaveBeenCalledWith(MatrixEventEvent.BeforeRedaction, expect.any(Function));
expect(roomStateOffSpy).toHaveBeenCalledWith(RoomStateEvent.Events, expect.any(Function));
expect(SettingsStore.unwatchSetting).toHaveBeenCalledWith("mediaPreviewConfig:!room:example.org");
expect(SettingsStore.unwatchSetting).toHaveBeenCalledWith("showMediaEventIds:global");
});
it("routes resend and cancel actions to the actionable failed event variant", () => {
const mxEvent = createMessageEvent();
const localRedactionEvent = createMessageEvent({ event_id: "$redaction" });
const replacingEvent = createMessageEvent({ event_id: "$replacement" });
localRedactionEvent.setStatus(EventStatus.SENT);
replacingEvent.setStatus(EventStatus.QUEUED);
jest.spyOn(mxEvent, "localRedactionEvent").mockReturnValue(localRedactionEvent);
jest.spyOn(mxEvent, "replacingEvent").mockReturnValue(replacingEvent);
const vm = createVm({ mxEvent });
vm.onResendClick(null);
vm.onCancelClick(null);
expect(Resend.resend).toHaveBeenCalledWith(client, localRedactionEvent);
expect(Resend.removeFromQueue).toHaveBeenCalledWith(client, replacingEvent);
});
it("downloads a cached blob and shows an error dialog on failure", async () => {
const blob = new Blob(["downloaded"]);
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(true);
const vm = createVm({
mxEvent: createMessageEvent({
content: { msgtype: MsgType.Image, body: "Image", url: "mxc://example.org/file" },
}),
});
(vm as unknown as { downloadedBlob: Blob }).downloadedBlob = blob;
await vm.onDownloadClick(null);
await vm.onDownloadClick(null);
expect(mockDownload).toHaveBeenNthCalledWith(1, { blob, name: "Image" });
expect(mockDownload).toHaveBeenNthCalledWith(2, { blob, name: "Image" });
mockDownload.mockRejectedValueOnce(new Error("boom"));
await vm.onDownloadClick(null);
expect(Modal.createDialog).toHaveBeenCalledWith(
ErrorDialog,
expect.objectContaining({
title: "Download failed",
description: expect.stringContaining("boom"),
}),
);
expect(vm.getSnapshot().isDownloadLoading).toBe(false);
});
it("ignores stale download completion after setProps changes the event", async () => {
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(true);
const firstDownload = createPendingPromise<void>();
const eventA = createMessageEvent({
event_id: "$eventA",
content: { msgtype: MsgType.Image, body: "Image A", url: "mxc://example.org/a" },
});
const eventB = createMessageEvent({
event_id: "$eventB",
content: { msgtype: MsgType.Image, body: "Image B", url: "mxc://example.org/b" },
});
const vm = createVm({ mxEvent: eventA });
(vm as unknown as { downloadedBlob: Blob }).downloadedBlob = new Blob(["a"]);
mockDownload.mockReturnValueOnce(firstDownload.promise);
const firstDownloadCall = vm.onDownloadClick(null);
expect(vm.getSnapshot().isDownloadLoading).toBe(true);
vm.setProps({ mxEvent: eventB });
(vm as unknown as { downloadedBlob: Blob }).downloadedBlob = new Blob(["b"]);
expect(vm.getSnapshot().isDownloadLoading).toBe(false);
const secondDownload = vm.onDownloadClick(null);
await secondDownload;
firstDownload.resolve();
await firstDownloadCall;
expect(mockDownload).toHaveBeenCalledTimes(2);
expect(mockDownload).toHaveBeenNthCalledWith(2, {
blob: expect.any(Blob),
name: "Image B",
});
expect(vm.getSnapshot().isDownloadLoading).toBe(false);
});
it("ignores stale download permission results after dispose", async () => {
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(true);
const permission = createPendingPromise<boolean>();
const event = createMessageEvent({
event_id: "$eventA",
content: { msgtype: MsgType.Image, body: "Image A", url: "mxc://example.org/a" },
});
getHintsForMessageSpy.mockReturnValue({
allowDownloadingMedia: jest.fn().mockReturnValue(permission.promise),
} as never);
const vm = createVm({ mxEvent: event });
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
vm.dispose();
permission.resolve(true);
await Promise.resolve();
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
});
it("dispatches reply and thread actions and forwards callbacks", async () => {
const onOptionsClick = jest.fn();
const onReactionsClick = jest.fn();
const onToggleThreadExpanded = jest.fn();
const threadReply = createMessageEvent({
sender: "@bob:example.org",
event_id: "$reply",
content: {
"msgtype": MsgType.Text,
"body": "Reply",
"m.relates_to": {
rel_type: RelationType.Thread,
event_id: rootEvent.getId(),
},
},
});
Object.defineProperty(threadReply, "isThreadRoot", { value: false });
jest.spyOn(threadReply, "getThread").mockReturnValue({ rootEvent } as never);
const vm = createVm({
mxEvent: threadReply,
isCard: true,
onOptionsClick,
onReactionsClick,
onToggleThreadExpanded,
});
mocked(PinningUtils.isPinned).mockReturnValue(false);
vm.onReplyClick(null);
vm.onReplyInThreadClick(null);
vm.onEditClick(null);
await vm.onPinClick(null);
vm.onHideClick(null);
vm.onOptionsClick(null);
vm.onReactionsClick(null);
vm.onToggleThreadExpanded(null);
expect(defaultDispatcher.dispatch).toHaveBeenNthCalledWith(1, {
action: "reply_to_event",
event: threadReply,
context: TimelineRenderingType.Room,
});
expect(defaultDispatcher.dispatch).toHaveBeenNthCalledWith(2, {
action: Action.ShowThread,
rootEvent,
initialEvent: threadReply,
scroll_into_view: true,
highlighted: true,
push: true,
});
expect(editEvent).toHaveBeenCalledWith(client, threadReply, TimelineRenderingType.Room, undefined);
expect(PinningUtils.pinOrUnpinEvent).toHaveBeenCalledWith(client, threadReply);
expect(PosthogTrackers.trackPinUnpinMessage).toHaveBeenCalledWith(expect.any(String), "Timeline");
expect(setMediaVisibility).toHaveBeenCalledWith(threadReply, false);
expect(onOptionsClick).toHaveBeenCalledWith(null);
expect(onReactionsClick).toHaveBeenCalledWith(null);
expect(onToggleThreadExpanded).toHaveBeenCalledWith(null);
});
describe("business logic parity", () => {
it.each([
{
name: "hides reply and react for non-actionable events",
actionable: false,
props: {},
expectedActions: [],
unexpectedActions: [ActionBarAction.Reply, ActionBarAction.React],
},
{
name: "hides reply when sending messages is disabled",
actionable: true,
props: { canSendMessages: false },
expectedActions: [ActionBarAction.React],
unexpectedActions: [ActionBarAction.Reply],
},
{
name: "hides react when reactions are disabled",
actionable: true,
props: { canReact: false },
expectedActions: [ActionBarAction.Reply],
unexpectedActions: [ActionBarAction.React],
},
{
name: "hides react in search results",
actionable: true,
props: { isSearch: true },
expectedActions: [ActionBarAction.Reply],
unexpectedActions: [ActionBarAction.React],
},
])("$name", ({ actionable, props, expectedActions, unexpectedActions }) => {
mocked(isContentActionable).mockReturnValue(actionable);
const vm = createVm(props);
expectedActions.forEach((action) => expect(vm.getSnapshot().actions).toContain(action));
unexpectedActions.forEach((action) => expect(vm.getSnapshot().actions).not.toContain(action));
});
it.each([
{
name: "shows expand collapse only when quote state is provided and reply should display",
quoteExpanded: true,
displayReply: true,
expected: true,
},
{
name: "hides expand collapse when quote state is missing",
quoteExpanded: undefined,
displayReply: true,
expected: false,
},
{
name: "hides expand collapse when reply should not display",
quoteExpanded: false,
displayReply: false,
expected: false,
},
])("$name", ({ quoteExpanded, displayReply, expected }) => {
mocked(shouldDisplayReply).mockReturnValue(displayReply);
const vm = createVm({ isQuoteExpanded: quoteExpanded });
expect(vm.getSnapshot().actions.includes(ActionBarAction.Expand)).toBe(expected);
});
it.each([
{
name: "allows reply in thread for normal room messages in room timeline",
timelineRenderingType: TimelineRenderingType.Room,
content: { msgtype: MsgType.Text, body: "Hello" },
relation: undefined,
type: EventType.RoomMessage,
expectedReplyInThread: true,
expectedAllowed: true,
},
{
name: "blocks reply in thread in thread timeline",
timelineRenderingType: TimelineRenderingType.Thread,
content: { msgtype: MsgType.Text, body: "Hello" },
relation: undefined,
type: EventType.RoomMessage,
expectedReplyInThread: false,
expectedAllowed: true,
},
{
name: "blocks reply in thread for verification requests",
timelineRenderingType: TimelineRenderingType.Room,
content: { msgtype: MsgType.KeyVerificationRequest, body: "verify" },
relation: undefined,
type: EventType.RoomMessage,
expectedReplyInThread: false,
expectedAllowed: true,
},
{
name: "blocks reply in thread for beacon info events",
timelineRenderingType: TimelineRenderingType.Room,
content: {},
relation: undefined,
type: M_BEACON_INFO.name,
expectedReplyInThread: false,
expectedAllowed: true,
},
{
name: "marks non-thread relations as not thread reply allowed",
timelineRenderingType: TimelineRenderingType.Room,
content: { msgtype: MsgType.Text, body: "Hello" },
relation: { rel_type: RelationType.Annotation },
type: EventType.RoomMessage,
expectedReplyInThread: true,
expectedAllowed: false,
},
])("$name", ({ timelineRenderingType, content, relation, type, expectedReplyInThread, expectedAllowed }) => {
const mxEvent = new MatrixEvent({
type,
room_id: roomId,
sender: userId,
event_id: "$scenario",
content,
});
jest.spyOn(mxEvent, "getRelation").mockReturnValue(relation as never);
const vm = createVm({ mxEvent, timelineRenderingType });
expect(vm.getSnapshot().actions.includes(ActionBarAction.ReplyInThread)).toBe(expectedReplyInThread);
expect(vm.getSnapshot().isThreadReplyAllowed).toBe(expectedAllowed);
});
it("shows thread action for deleted messages with a thread in the room timeline", () => {
const mxEvent = createMessageEvent();
mocked(isContentActionable).mockReturnValue(false);
jest.spyOn(mxEvent, "getThread").mockReturnValue({ rootEvent } as never);
const vm = createVm({ mxEvent, timelineRenderingType: TimelineRenderingType.Room });
expect(vm.getSnapshot().actions).toContain(ActionBarAction.ReplyInThread);
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Reply);
});
it("matches media visibility rules for hide and download actions", async () => {
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(true);
jest.spyOn(MediaEventHelper, "canHide").mockReturnValue(true);
getHintsForMessageSpy.mockReturnValue({
allowDownloadingMedia: jest.fn().mockResolvedValue(false),
} as never);
const mxEvent = createMessageEvent({
content: { msgtype: MsgType.Image, body: "Image", file: { url: "mxc://example.org/file" } },
});
const vm = createVm({ mxEvent });
expect(vm.getSnapshot()).toMatchObject({
isDownloadEncrypted: true,
});
expect(vm.getSnapshot().actions).toContain(ActionBarAction.Hide);
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
await waitFor(() => expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download));
});
it("recomputes parity-relevant flags and resets download state when the event changes", () => {
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(true);
const vm = createVm({
mxEvent: createMessageEvent({
event_id: "$image",
content: { msgtype: MsgType.Image, body: "Image", url: "mxc://example.org/file" },
}),
});
(vm as unknown as { downloadedBlob?: Blob; isDownloadLoading: boolean }).downloadedBlob = new Blob(["x"]);
(vm as unknown as { downloadedBlob?: Blob; isDownloadLoading: boolean }).isDownloadLoading = true;
mocked(isContentActionable).mockReturnValue(false);
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(false);
vm.setProps({
mxEvent: createMessageEvent({
event_id: "$text",
content: { msgtype: MsgType.Text, body: "Text" },
}),
});
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Hide);
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Reply);
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.React);
expect(vm.getSnapshot().isDownloadLoading).toBe(false);
});
});
});

View File

@ -75,5 +75,8 @@ export default {
nx: {
config: ["{nx,package,project}.json", "{apps,packages,modules}/**/{package,project}.json"],
},
playwright: {
config: ["playwright.config.ts", "playwright-merge.config.ts"],
},
tags: ["-knipignore"],
} satisfies KnipConfig;

View File

@ -27,9 +27,11 @@
"devDependencies": {
"@action-validator/cli": "^0.6.0",
"@action-validator/core": "^0.6.0",
"@element-hq/element-web-playwright-common": "catalog:",
"@nx-tools/nx-container": "^7.2.1",
"@nx/jest": "^22.5.0",
"@types/node": "22",
"@playwright/test": "catalog:",
"cronstrue": "^3.0.0",
"eslint-plugin-matrix-org": "^3.0.0",
"husky": "^9.0.0",
@ -126,6 +128,6 @@
"engines": {
"node": ">=22.18"
},
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
"private": true
}

View File

@ -26,7 +26,7 @@ type PaginationLinks = {
// We see quite a few test flakes which are caused by the app exploding
// so we have some magic strings we check the logs for to better track the flake with its cause
const SPECIAL_CASES = {
const SPECIAL_CASES: Record<string, string> = {
"ChunkLoadError": "ChunkLoadError",
"Unreachable code should not be executed": "Rust crypto panic",
"Out of bounds memory access": "Rust crypto memory error",
@ -37,7 +37,7 @@ class FlakyReporter implements Reporter {
public onTestEnd(test: TestCase): void {
// Ignores flakes on Dendrite and Pinecone as they have their own flakes we do not track
if (["Dendrite", "Pinecone"].includes(test.parent.project()?.name)) return;
if (["Dendrite", "Pinecone"].includes(test.parent.project()!.name!)) return;
let failures = [`${test.location.file.split("playwright/e2e/")[1]}: ${test.title}`];
if (test.outcome() === "flaky") {
const timedOutRuns = test.results.filter((result) => result.status === "timedOut");
@ -46,7 +46,7 @@ class FlakyReporter implements Reporter {
);
// If a test failed due to a systemic fault then the test is not flaky, the app is, record it as such.
const specialCases = Object.keys(SPECIAL_CASES).filter((log) =>
pageLogs.some((attachment) => attachment.name.startsWith("page-") && attachment.body.includes(log)),
pageLogs.some((attachment) => attachment.name.startsWith("page-") && attachment.body?.includes(log)),
);
if (specialCases.length > 0) {
failures = specialCases.map((specialCase) => SPECIAL_CASES[specialCase]);
@ -56,7 +56,7 @@ class FlakyReporter implements Reporter {
if (!this.flakes.has(title)) {
this.flakes.set(title, []);
}
this.flakes.get(title).push(test);
this.flakes.get(title)!.push(test);
}
}
}
@ -76,8 +76,8 @@ class FlakyReporter implements Reporter {
if (!link) return map;
const matches = link.matchAll(/(<(?<link>.+?)>; rel="(?<type>.+?)")/g);
for (const match of matches) {
const { link, type } = match.groups;
map[type] = link;
const { link, type } = match.groups!;
map[type as keyof PaginationLinks] = link;
}
return map;
}
@ -102,9 +102,9 @@ class FlakyReporter implements Reporter {
issues.push(...fetchedIssues);
// Get the next link for fetching more results
const linkHeader = issuesResponse.headers.get("Link");
const linkHeader = issuesResponse.headers.get("Link")!;
const parsed = this.parseLinkHeader(linkHeader);
url = parsed.next;
url = parsed.next!;
}
return issues;
}

View File

@ -25,6 +25,24 @@ vis.setup({
*, *::before, *::after {
animation: none !important;
}
/*
* Mask spinner for video overlay during screenshot generation on playwright tests.
*/
[data-video-body-mask-target] {
position: relative;
}
[data-video-body-mask-target]::after {
content: "";
position: absolute;
inset-inline-start: 50%;
inset-block-start: 50%;
width: 112px;
height: 112px;
transform: translate(-50%, -50%);
border-radius: 999px;
background: #ff4fcf;
pointer-events: none;
}
/* Hide all storybook elements */
.sb-wrapper {
visibility: hidden !important;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -121,7 +121,7 @@
"engines": {
"node": ">=20.0.0"
},
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
"peerDependencies": {
"@vector-im/compound-web": "^8.3.5"
}

Some files were not shown because too many files have changed in this diff Show More