Merge branch 'develop' into andybalaam/always-nag-to-verify-even-if-no-e2e-rooms
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
89
.github/workflows/build_desktop_and_test.yaml
vendored
@ -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
|
||||
24
.github/workflows/build_desktop_linux.yaml
vendored
@ -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
|
||||
|
||||
8
.github/workflows/build_desktop_macos.yaml
vendored
@ -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'
|
||||
|
||||
36
.github/workflows/build_desktop_prepare.yaml
vendored
@ -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
|
||||
|
||||
18
.github/workflows/build_desktop_test.yaml
vendored
@ -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
|
||||
|
||||
8
.github/workflows/build_desktop_windows.yaml
vendored
@ -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 != ''
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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
|
||||
@ -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" },
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
@ -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";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
58
apps/web/res/css/views/messages/_ThreadActionBar.pcss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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)!;
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
122
apps/web/src/utils/media/mediaVisibility.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
543
apps/web/src/viewmodels/message-body/VideoBodyViewModel.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
}));
|
||||
}
|
||||
|
||||
504
apps/web/src/viewmodels/room/EventTileActionBarViewModel.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
||||
57
apps/web/src/viewmodels/room/ThreadListActionBarViewModel.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
@ -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 }));
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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({
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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");
|
||||
});
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
`;
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
3
knip.ts
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 29 KiB |
@ -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"
|
||||
}
|
||||
|
||||