Compare commits

..

2 Commits

Author SHA1 Message Date
David Langley
a415bce803 Fix tests and lint 2025-09-19 15:50:51 +01:00
David Langley
705dcd39f4 Fix html export when feature_jump_to_date is enabled 2025-09-19 15:04:11 +01:00
802 changed files with 9984 additions and 30575 deletions

View File

@@ -1,13 +1,11 @@
/*
Copyright 2025 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.
*/
module.exports = {
plugins: ["matrix-org", "eslint-plugin-react-compiler"],
extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"],
extends: [
"plugin:matrix-org/babel",
"plugin:matrix-org/react",
"plugin:matrix-org/a11y",
"plugin:storybook/recommended",
],
parserOptions: {
project: ["./tsconfig.json"],
},

7
.github/CODEOWNERS vendored
View File

@@ -18,10 +18,9 @@
/playwright/e2e/settings/encryption-user-tab/ @element-hq/element-crypto-web-reviewers
/src/models/Call.ts @element-hq/element-call-reviewers
/src/call-types.ts @element-hq/element-call-reviewers
/src/components/views/voip @element-hq/element-call-reviewers
/playwright/e2e/voip/element-call.spec.ts @element-hq/element-call-reviewers
/src/models/Call.ts @element-hq/element-call-reviewers
/src/call-types.ts @element-hq/element-call-reviewers
/src/components/views/voip @element-hq/element-call-reviewers
# Ignore translations as those will be updated by GHA for Localazy download
/src/i18n/strings

View File

@@ -10,7 +10,8 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# develop pushes and repository_dispatch handled in build_develop.yaml
env:
# This must be set for fetchdep.sh to get the right branch
# These must be set for fetchdep.sh to get the right branch
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
permissions: {} # No permissions required
jobs:
@@ -44,7 +45,7 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
# Disable cache on Windows as it is slower than not caching
# https://github.com/actions/setup-node/issues/975
@@ -55,7 +56,15 @@ jobs:
- run: yarn config set network-timeout 300000
- name: Fetch layered build
run: ./scripts/layered.sh
id: layered_build
env:
# tell layered.sh to check out the right sha of the JS-SDK & EW, if they were given one
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
run: |
scripts/layered.sh
JSSDK_SHA=$(git -C matrix-js-sdk rev-parse --short=12 HEAD)
VECTOR_SHA=$(git rev-parse --short=12 HEAD)
echo "VERSION=$VECTOR_SHA--js-$JSSDK_SHA" >> $GITHUB_OUTPUT
- name: Copy config
run: cp element.io/develop/config.json config.json
@@ -63,10 +72,12 @@ jobs:
- name: Build
env:
CI_PACKAGE: true
run: VERSION=$(scripts/get-version-from-git.sh) yarn build
VERSION: "${{ steps.layered_build.outputs.VERSION }}"
run: |
yarn build
- name: Upload Artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: webapp-${{ matrix.image }}
path: webapp

View File

@@ -62,7 +62,7 @@ jobs:
dpkg-gencontrol -v"$VERSION" -ldebian/tmp/DEBIAN/changelog
dpkg-deb -Zxz --root-owner-group --build debian/tmp element-web.deb
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: element-web.deb
path: element-web.deb

View File

@@ -28,7 +28,7 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
cache: "yarn"
node-version: "lts/*"
@@ -53,7 +53,7 @@ jobs:
- run: mv dist/element-*.tar.gz dist/develop.tar.gz
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: webapp
path: dist/develop.tar.gz

View File

@@ -37,14 +37,14 @@ jobs:
install: true
- name: Login to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3
if: github.event_name != 'pull_request'
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
@@ -132,7 +132,7 @@ jobs:
cosign sign --yes ${images}
- name: Update repo description
uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5
uses: peter-evans/dockerhub-description@432a30c9e07499fd01da9f8a49f0faf9e0ca5b77 # v4
if: github.event_name != 'pull_request'
continue-on-error: true
with:
@@ -141,7 +141,7 @@ jobs:
repository: vectorim/element-web
- name: Repository Dispatch
uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3
if: github.event_name != 'pull_request'
with:
repository: element-hq/element-web-pro

View File

@@ -33,7 +33,7 @@ jobs:
repository: matrix-org/matrix-js-sdk
path: matrix-js-sdk
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
cache: "yarn"
cache-dependency-path: element-web/yarn.lock

View File

@@ -25,7 +25,7 @@ jobs:
actions: read
steps:
- name: Download HTML report
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}

View File

@@ -54,16 +54,21 @@ jobs:
with:
repository: element-hq/element-web
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
cache: "yarn"
node-version: "lts/*"
- name: Fetch layered build
id: layered_build
env:
# tell layered.sh to check out the right sha of the JS-SDK & EW, if they were given one
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
run: scripts/layered.sh
run: |
scripts/layered.sh
JSSDK_SHA=$(git -C matrix-js-sdk rev-parse --short=12 HEAD)
VECTOR_SHA=$(git rev-parse --short=12 HEAD)
echo "VERSION=$VECTOR_SHA--js-$JSSDK_SHA" >> $GITHUB_OUTPUT
- name: Copy config
run: cp element.io/develop/config.json config.json
@@ -71,10 +76,12 @@ jobs:
- name: Build
env:
CI_PACKAGE: true
run: VERSION=$(scripts/get-version-from-git.sh) yarn build
VERSION: "${{ steps.layered_build.outputs.VERSION }}"
run: |
yarn build
- name: Upload Artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: webapp
path: webapp
@@ -128,12 +135,12 @@ jobs:
repository: element-hq/element-web
- name: 📥 Download artifact
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
with:
name: webapp
path: webapp
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
cache: "yarn"
cache-dependency-path: yarn.lock
@@ -147,7 +154,7 @@ jobs:
run: echo "version=$(yarn list --pattern @playwright/test --depth=0 --json --non-interactive --no-progress | jq -r '.data.trees[].name')" >> $GITHUB_OUTPUT
- name: Cache playwright binaries
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
@@ -172,7 +179,7 @@ jobs:
- name: Upload blob report to GitHub Actions Artifacts
if: always()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: all-blob-reports-${{ matrix.project }}-${{ matrix.runner }}
path: blob-report
@@ -200,7 +207,7 @@ jobs:
persist-credentials: false
repository: element-hq/element-web
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
if: inputs.skip != true
with:
cache: "yarn"
@@ -212,7 +219,7 @@ jobs:
- name: Download blob reports from GitHub Actions Artifacts
if: inputs.skip != true
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
with:
pattern: all-blob-reports-*
path: all-blob-reports
@@ -228,7 +235,7 @@ jobs:
# Upload the HTML report even if one of our reporters fails, this can happen when stale screenshots are detected
- name: Upload HTML report
if: always() && inputs.skip != true
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: html-report
path: playwright-report

View File

@@ -28,7 +28,7 @@ jobs:
Exercise caution. Use test accounts.
- name: 📥 Download artifact
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}

View File

@@ -1,40 +0,0 @@
name: Publish shared component npm package
on:
workflow_dispatch: {}
concurrency: release
jobs:
publish:
name: "Publish"
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- name: 🧮 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: 🔧 Set up node environment
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
with:
cache: "yarn"
node-version-file: ".node-version"
registry-url: "https://registry.npmjs.org"
# Ensure npm 11.5.1 or later is installed
- name: Update npm
run: npm install -g npm@latest
# Need to setup element web too as it needs the translations
- name: 🛠️ Setup EW
run: yarn install --pure-lockfile
- name: 🛠️ Setup
# When running `install` it also calls the `prepare` step which generates
# a build
run: yarn --cwd packages/shared-components install --pure-lockfile
- name: 🚀 Publish to npm
working-directory: packages/shared-components
run: npm publish --access public --tag test --provenance

View File

@@ -27,7 +27,7 @@ jobs:
run: "sudo apt-get install -y tree"
- name: Download Diffs
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}

View File

@@ -26,41 +26,45 @@ jobs:
persist-credentials: false
repository: element-hq/element-web
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
cache: "yarn"
node-version: "lts/*"
- name: Install element web dependencies
run: yarn install --frozen-lockfile
- name: Install dependencies
working-directory: packages/shared-components
run: yarn install --frozen-lockfile
- name: Get installed Playwright version
working-directory: packages/shared-components
id: playwright
run: echo "version=$(yarn list --pattern @playwright/test --depth=0 --json --non-interactive --no-progress | jq -r '.data.trees[].name')" >> $GITHUB_OUTPUT
- name: Cache playwright binaries
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright.outputs.version }}-onlyshell
- name: Install Playwright browsers
working-directory: packages/shared-components
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: "yarn playwright install --with-deps --only-shell"
- name: Build Element Web resources
# Needed to prepare language files
run: "yarn build:res"
- name: Build storybook dependencies
# When the first test is ran, it will fail because the dependencies are not yet built.
# This step is to ensure that the dependencies are built before running the tests.
run: "yarn test:storybook:ci"
continue-on-error: true
- name: Run Visual tests
run: "yarn --cwd packages/shared-components test:storybook:ci"
run: "yarn test:storybook:ci"
- name: Upload received images & diffs
if: always()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: received-images
path: packages/shared-components/playwright/shared-component-received
path: playwright/shared-component-received

View File

@@ -12,7 +12,8 @@ concurrency:
cancel-in-progress: true
env:
# This must be set for fetchdep.sh to get the right branch
# These must be set for fetchdep.sh to get the right branch
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
permissions: {} # No permissions required
@@ -24,7 +25,7 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
cache: "yarn"
node-version: "lts/*"
@@ -35,12 +36,6 @@ jobs:
- name: Typecheck
run: "yarn run lint:types"
- name: Install Shared Component Dependencies
run: "yarn --cwd packages/shared-components install"
- name: Typecheck Shared Components
run: "yarn --cwd packages/shared-components run lint:types"
i18n_lint:
name: "i18n Check"
uses: matrix-org/matrix-web-i18n/.github/workflows/i18n_check.yml@main
@@ -75,7 +70,7 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
cache: "yarn"
node-version: "lts/*"
@@ -87,19 +82,13 @@ jobs:
- name: Run Linter
run: "yarn run lint:js"
- name: Install Shared Component Deps
run: "yarn --cwd packages/shared-components install --frozen-lockfile"
- name: Run Linter
run: "yarn --cwd packages/shared-components run lint:js"
style_lint:
name: "Style Lint"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
cache: "yarn"
node-version: "lts/*"
@@ -117,7 +106,7 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
cache: "yarn"
node-version: "lts/*"
@@ -135,7 +124,7 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
cache: "yarn"
node-version: "lts/*"

View File

@@ -29,8 +29,8 @@ env:
permissions: {}
jobs:
jest_ew:
name: Jest (Element Web)
jest:
name: Jest
runs-on: ubuntu-24.04
strategy:
fail-fast: false
@@ -44,7 +44,7 @@ jobs:
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-web' || github.repository }}
- name: Yarn cache
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: "lts/*"
cache: "yarn"
@@ -55,7 +55,7 @@ jobs:
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
- name: Jest Cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4
with:
path: /tmp/jest_cache
key: ${{ hashFiles('**/yarn.lock') }}
@@ -84,7 +84,7 @@ jobs:
- name: Upload Artifact
if: env.ENABLE_COVERAGE == 'true'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: coverage-${{ matrix.runner }}
path: |
@@ -93,18 +93,18 @@ jobs:
complete:
name: jest-tests
needs: jest_ew
needs: jest
if: always()
runs-on: ubuntu-24.04
permissions:
statuses: write
steps:
- if: needs.jest_ew.result != 'skipped' && needs.jest_ew.result != 'success'
- if: needs.jest.result != 'skipped' && needs.jest.result != 'success'
run: exit 1
- name: Skip SonarCloud in merge queue
if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true'
uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
@@ -112,56 +112,3 @@ jobs:
context: SonarCloud Code Analysis
sha: ${{ github.sha }}
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
jest_sc:
name: Jest (Shared Components)
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-web' || github.repository }}
- name: Yarn cache
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
with:
node-version: "lts/*"
cache: "yarn"
- name: Install EW Deps
run: "yarn install"
- name: Install Shared Component Deps
working-directory: "packages/shared-components"
run: "yarn install"
- name: Jest Cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: /tmp/jest_cache
key: ${{ hashFiles('**/yarn.lock') }}
- name: Get number of CPU cores
id: cpu-cores
uses: SimenB/github-actions-cpu-cores@97ba232459a8e02ff6121db9362b09661c875ab8 # v2
- name: Run tests
working-directory: "packages/shared-components"
run: |
yarn test \
--coverage=${{ env.ENABLE_COVERAGE }} \
--ci \
--max-workers ${{ steps.cpu-cores.outputs.count }} \
--cacheDirectory /tmp/jest_cache
env:
# tell jest to use coloured output
FORCE_COLOR: true
- name: Upload Artifact
if: env.ENABLE_COVERAGE == 'true'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
with:
name: coverage-sharedcomponents
path: |
packages/shared-components/coverage
!packages/shared-components/coverage/lcov-report

View File

@@ -12,7 +12,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10
- uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10
with:
operations-per-run: 100

View File

@@ -11,7 +11,7 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
cache: "yarn"
node-version: "lts/*"

5
.gitignore vendored
View File

@@ -4,6 +4,7 @@
/key.pem
/lib
/node_modules
/packages/
/webapp
/.npmrc
/*.log
@@ -33,7 +34,3 @@ electron/pub
*storybook.log
storybook-static
/packages/shared-components/node_modules
/packages/shared-components/dist
/packages/shared-components/src/i18nKeys.d.ts

View File

@@ -1 +1 @@
24
22

View File

@@ -12,7 +12,7 @@ import { GlobeIcon } from "@storybook/icons";
// We can't import `shared/i18n.tsx` directly here.
// The storybook addon doesn't seem to benefit the vite config of storybook and we can't resolve the alias in i18n.tsx.
import json from "../../../webapp/i18n/languages.json";
import json from "../webapp/i18n/languages.json";
const languages = Object.keys(json).filter((lang) => lang !== "default");
/**

View File

@@ -11,8 +11,8 @@ import { nodePolyfills } from "vite-plugin-node-polyfills";
import { mergeConfig } from "vite";
const config: StorybookConfig = {
stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
staticDirs: ["../../../webapp"],
stories: ["../src/shared-components/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
staticDirs: ["../webapp"],
addons: ["@storybook/addon-docs", "@storybook/addon-designs", "@storybook/addon-a11y"],
framework: "@storybook/react-vite",
core: {
@@ -26,7 +26,7 @@ const config: StorybookConfig = {
resolve: {
alias: {
// Alias used by i18n.tsx
$webapp: path.resolve("../../webapp"),
$webapp: path.resolve("webapp"),
},
},
// Needed for counterpart to work
@@ -36,11 +36,5 @@ const config: StorybookConfig = {
},
});
},
refs: {
"compound-web": {
title: "Compound Web",
url: "https://element-hq.github.io/compound-web/",
},
},
};
export default config;

View File

@@ -1,9 +1,9 @@
import type { ArgTypes, Preview, Decorator, ReactRenderer, StrictArgs } from "@storybook/react-vite";
import "../../../res/css/shared.pcss";
import "../res/css/shared.pcss";
import "./preview.css";
import React, { useLayoutEffect } from "react";
import { setLanguage } from "../src/utils/i18n";
import { setLanguage } from "../src/shared-components/utils/i18n";
import { TooltipProvider } from "@vector-im/compound-web";
import { StoryContext } from "storybook/internal/csf";

View File

@@ -8,8 +8,8 @@ Please see LICENSE files in the repository root for full details.
import { waitForPageReady } from "@storybook/test-runner";
import { toMatchImageSnapshot } from "jest-image-snapshot";
const customSnapshotsDir = `${process.cwd()}/playwright/snapshots/`;
const customReceivedDir = `${process.cwd()}/playwright/received/`;
const customSnapshotsDir = `${process.cwd()}/playwright/shared-component-snapshots/`;
const customReceivedDir = `${process.cwd()}/playwright/shared-component-received/`;
/**
* @type {import('@storybook/test-runner').TestRunnerConfig}

View File

@@ -1,115 +1,3 @@
Changes in [1.12.3](https://github.com/element-hq/element-web/releases/tag/v1.12.3) (2025-11-04)
================================================================================================
## 🦖 Deprecations
* Remove allowVoipWithNoMedia feature flag ([#31087](https://github.com/element-hq/element-web/pull/31087)). Contributed by @Half-Shot.
## ✨ Features
* Change module API to be an instance getter ([#31025](https://github.com/element-hq/element-web/pull/31025)). Contributed by @dbkr.
## 🐛 Bug Fixes
* Show hover elements when keyboard focus is within an event tile ([#31078](https://github.com/element-hq/element-web/pull/31078)). Contributed by @t3chguy.
* Ensure toolbar navigation pattern works in MessageActionBar ([#31080](https://github.com/element-hq/element-web/pull/31080)). Contributed by @t3chguy.
* Ensure sent markers are hidden when showing thread summary. ([#31076](https://github.com/element-hq/element-web/pull/31076)). Contributed by @Half-Shot.
* Fix translation in dev mode ([#31045](https://github.com/element-hq/element-web/pull/31045)). Contributed by @florianduros.
* Fix sort order in space hierarchy ([#30975](https://github.com/element-hq/element-web/pull/30975)). Contributed by @t3chguy.
* New Room list: don't display message preview of thread ([#31043](https://github.com/element-hq/element-web/pull/31043)). Contributed by @florianduros.
* Revert "A11y: move focus to right panel when opened" ([#30999](https://github.com/element-hq/element-web/pull/30999)). Contributed by @florianduros.
* Fix highlights in messages (or search results) breaking links ([#30264](https://github.com/element-hq/element-web/pull/30264)). Contributed by @bojidar-bg.
* Add prepare script ([#31030](https://github.com/element-hq/element-web/pull/31030)). Contributed by @dbkr.
* Fix html exports by adding SDKContext ([#30987](https://github.com/element-hq/element-web/pull/30987)). Contributed by @t3chguy.
Changes in [1.12.2](https://github.com/element-hq/element-web/releases/tag/v1.12.2) (2025-10-21)
================================================================================================
## ✨ Features
* Room List: Extend the viewport to avoid so many black spots when scrolling the room list ([#30867](https://github.com/element-hq/element-web/pull/30867)). Contributed by @langleyd.
* Hide calling buttons in room header before a room is created ([#30816](https://github.com/element-hq/element-web/pull/30816)). Contributed by @Half-Shot.
* Improve invite dialog ui - Part 2 ([#30836](https://github.com/element-hq/element-web/pull/30836)). Contributed by @florianduros.
## 🐛 Bug Fixes
* Fix platform settings race condition and make auto-launch tri-state ([#30977](https://github.com/element-hq/element-web/pull/30977)). Contributed by @t3chguy.
* Fix: member count in header and member list ([#30982](https://github.com/element-hq/element-web/pull/30982)). Contributed by @florianduros.
* Fix duration of voice message in timeline ([#30973](https://github.com/element-hq/element-web/pull/30973)). Contributed by @florianduros.
* Fix voice notes rendering at 00:00 when playback had not begun. ([#30961](https://github.com/element-hq/element-web/pull/30961)). Contributed by @Half-Shot.
* Improve handling of animated images, add support for AVIF animations ([#30932](https://github.com/element-hq/element-web/pull/30932)). Contributed by @t3chguy.
* Update key storage toggle when key storage status changes ([#30934](https://github.com/element-hq/element-web/pull/30934)). Contributed by @uhoreg.
* Fix jitsi widget popout ([#30908](https://github.com/element-hq/element-web/pull/30908)). Contributed by @dbkr.
* Improve keyboard navigation on invite dialog ([#30930](https://github.com/element-hq/element-web/pull/30930)). Contributed by @florianduros.
* Prefer UIA flows with supported UIA stages ([#30926](https://github.com/element-hq/element-web/pull/30926)). Contributed by @richvdh.
* Enhance accessibility of dropdown ([#30928](https://github.com/element-hq/element-web/pull/30928)). Contributed by @florianduros.
* Improve accessibility of the `\<AvatarSetting> component ([#30907](https://github.com/element-hq/element-web/pull/30907)). Contributed by @MidhunSureshR.
Changes in [1.12.1](https://github.com/element-hq/element-web/releases/tag/v1.12.1) (2025-10-07)
================================================================================================
## ✨ Features
* New Room List: Change the order of filters to match those on mobile ([#30905](https://github.com/element-hq/element-web/pull/30905)). Contributed by @langleyd.
* New Room List: Don't clear filters on space change ([#30903](https://github.com/element-hq/element-web/pull/30903)). Contributed by @langleyd.
* Add release announcement for the sounds ([#30900](https://github.com/element-hq/element-web/pull/30900)). Contributed by @langleyd.
* Rich Text Editor: Add emoji suggestion support ([#30873](https://github.com/element-hq/element-web/pull/30873)). Contributed by @langleyd.
* feat: Disable session lock when running in element-desktop ([#30643](https://github.com/element-hq/element-web/pull/30643)). Contributed by @kaylendog.
* Improve invite dialog ui - Part 1 ([#30764](https://github.com/element-hq/element-web/pull/30764)). Contributed by @florianduros.
* Update Message Sound for Element ([#30804](https://github.com/element-hq/element-web/pull/30804)). Contributed by @beatdemon.
* Add new and improved ringtone ([#30761](https://github.com/element-hq/element-web/pull/30761)). Contributed by @Half-Shot.
* Disable RTE formatting buttons when the content contains a slash command ([#30802](https://github.com/element-hq/element-web/pull/30802)). Contributed by @langleyd.
## 🐛 Bug Fixes
* New Room List: Improve robustness of keyboard navigation ([#30888](https://github.com/element-hq/element-web/pull/30888)). Contributed by @langleyd.
* Fix a11y issue on list in invite dialog ([#30878](https://github.com/element-hq/element-web/pull/30878)). Contributed by @florianduros.
* Switch Export and Import Icons to match intuition ([#30805](https://github.com/element-hq/element-web/pull/30805)). Contributed by @micartey.
* Hide breadcrumb option when new room list is enabled ([#30869](https://github.com/element-hq/element-web/pull/30869)). Contributed by @florianduros.
* Avoid creating multiple call objects for the same widget ([#30839](https://github.com/element-hq/element-web/pull/30839)). Contributed by @robintown.
* Add a test for #29882, which is fixed by matrix-org/matrix-js-sdk#5016 ([#30835](https://github.com/element-hq/element-web/pull/30835)). Contributed by @andybalaam.
* fix: use `help_encryption_url` of config instead of hardcoded `https://element.io/help#encryption5` ([#30746](https://github.com/element-hq/element-web/pull/30746)). Contributed by @florianduros.
* Fix html export when feature\_jump\_to\_date is enabled ([#30828](https://github.com/element-hq/element-web/pull/30828)). Contributed by @langleyd.
* Fix #30439: "Forgot recovery key" should go to "reset" ([#30771](https://github.com/element-hq/element-web/pull/30771)). Contributed by @andybalaam.
Changes in [1.12.0](https://github.com/element-hq/element-web/releases/tag/v1.12.0) (2025-09-23)
================================================================================================
## 🦖 Deprecations
* Remove remaining support for outdated .well-known settings ([#30702](https://github.com/element-hq/element-web/pull/30702)). Contributed by @richvdh.
## ✨ Features
* Add decline button to call notification toast (use new notification event) ([#30729](https://github.com/element-hq/element-web/pull/30729)). Contributed by @toger5.
* Use the new room list by default ([#30640](https://github.com/element-hq/element-web/pull/30640)). Contributed by @langleyd.
* "Verify this device" redesign ([#30596](https://github.com/element-hq/element-web/pull/30596)). Contributed by @uhoreg.
* Set Element Call "intents" when starting and answering DM calls. ([#30730](https://github.com/element-hq/element-web/pull/30730)). Contributed by @Half-Shot.
* Add axe compliance for new room list ([#30700](https://github.com/element-hq/element-web/pull/30700)). Contributed by @langleyd.
* Stop ringing and remove toast if another device answers a RTC call. ([#30728](https://github.com/element-hq/element-web/pull/30728)). Contributed by @Half-Shot.
* Automatically adjust history visibility when making a room private ([#30713](https://github.com/element-hq/element-web/pull/30713)). Contributed by @Half-Shot.
* Release announcement for new room list ([#30675](https://github.com/element-hq/element-web/pull/30675)). Contributed by @dbkr.
## 🐛 Bug Fixes
* [Backport staging] Room list: make the filter resize correctly ([#30795](https://github.com/element-hq/element-web/pull/30795)). Contributed by @RiotRobot.
* [Backport staging] Avoid flicker of the room list filter on resize ([#30794](https://github.com/element-hq/element-web/pull/30794)). Contributed by @RiotRobot.
* Don't show release announcements while toasts are displayed ([#30770](https://github.com/element-hq/element-web/pull/30770)). Contributed by @dbkr.
* Fix enabling key backup not working if there is an untrusted key backup ([#30707](https://github.com/element-hq/element-web/pull/30707)). Contributed by @Half-Shot.
* Force `preload` to be false when setting an intent on an Element Call. ([#30759](https://github.com/element-hq/element-web/pull/30759)). Contributed by @Half-Shot.
* Fix handling of 413 server response when uploading media ([#30737](https://github.com/element-hq/element-web/pull/30737)). Contributed by @hughns.
* Make landmark navigation work with new room list ([#30747](https://github.com/element-hq/element-web/pull/30747)). Contributed by @dbkr.
* Prevent voice message from displaying spurious errors ([#30736](https://github.com/element-hq/element-web/pull/30736)). Contributed by @florianduros.
* Align default avatar and fix colors in composer pills ([#30739](https://github.com/element-hq/element-web/pull/30739)). Contributed by @florianduros.
* Use configured URL for link to desktop app in message search settings ([#30742](https://github.com/element-hq/element-web/pull/30742)). Contributed by @t3chguy.
* Fix history visibility when creating space rooms ([#30745](https://github.com/element-hq/element-web/pull/30745)). Contributed by @dbkr.
* Check HTML-encoded quotes when handling translations for embedded pages (such as welcome.html) ([#30743](https://github.com/element-hq/element-web/pull/30743)). Contributed by @Half-Shot.
* Fix local room encryption status always not enabled ([#30461](https://github.com/element-hq/element-web/pull/30461)). Contributed by @BillCarsonFr.
* fix: make url in topic in room intro clickable ([#30686](https://github.com/element-hq/element-web/pull/30686)). Contributed by @florianduros.
* Block change recovery key button while a change is ongoing. ([#30664](https://github.com/element-hq/element-web/pull/30664)). Contributed by @Half-Shot.
* Hide advanced settings during room creation when `UIFeature.advancedSettings=false` ([#30684](https://github.com/element-hq/element-web/pull/30684)). Contributed by @florianduros.
* A11y: improve accessibility of pinned messages ([#30558](https://github.com/element-hq/element-web/pull/30558)). Contributed by @florianduros.
Changes in [1.11.112](https://github.com/element-hq/element-web/releases/tag/v1.11.112) (2025-09-16)
====================================================================================================
Fix [CVE-2025-59161](https://www.cve.org/CVERecord?id=CVE-2025-59161) / [GHSA-m6c8-98f4-75rr](https://github.com/element-hq/element-web/security/advisories/GHSA-m6c8-98f4-75rr)

View File

@@ -1,7 +1,7 @@
# syntax=docker.io/docker/dockerfile:1.19-labs@sha256:dce1c693ef318bca08c964ba3122ae6248e45a1b96d65c4563c8dc6fe80349a2
# syntax=docker.io/docker/dockerfile:1.18-labs@sha256:79cdc14e1c220efb546ad14a8ebc816e3277cd72d27195ced5bebdd226dd1025
# Builder
FROM --platform=$BUILDPLATFORM node:24-bullseye@sha256:c102f42d665c164b4e5e5549813b1547ac8a9f1d343c7d17ddac106905a1c30b AS builder
FROM --platform=$BUILDPLATFORM node:22-bullseye@sha256:f8c398a3ad2612293e8827915c056ed0f5cc708b0f676274bb6c732e3c10f93d AS builder
# Support custom branch of the js-sdk. This also helps us build images of element-web develop.
ARG USE_CUSTOM_SDKS=false
@@ -19,7 +19,7 @@ RUN /src/scripts/docker-package.sh
RUN cp /src/config.sample.json /src/webapp/config.json
# App
FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:65a7f97c299b919190e96e38e2ff8358132732000d3bc5c00c07cc8763fca53f
FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:14b127ed799301a21a1798516443c675237120c76b9a738d43c5e4747de4b1c9
# Need root user to install packages & manipulate the usr directory
USER root

View File

@@ -1,69 +0,0 @@
# MVVM
_Deprecated_, see [MVVM.md](./MVVM.md) for the current version.
General description of the pattern can be found [here](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel). But the gist of it is that you divide your code into three sections:
1. Model: This is where the business logic and data resides.
2. View Model: This code exists to provide the logic necessary for the UI. It directly uses the Model code.
3. View: This is the UI code itself and depends on the view model.
If you do MVVM right, your view should be dumb i.e it gets data from the view model and merely displays it.
### Practical guidelines for MVVM in element-web
#### Model
This is anywhere your data or business logic comes from. If your view model is accessing something simple exposed from `matrix-js-sdk`, then the sdk is your model. If you're using something more high level in element-web to get your data/logic (eg: `MemberListStore`), then that becomes your model.
#### View Model
1. View model is always a custom react hook named like `useFooViewModel()`.
2. The return type of your view model (known as view state) must be defined as a typescript interface:
```ts
inteface FooViewState {
somethingUseful: string;
somethingElse: BarType;
update: () => Promise<void>
...
}
```
3. Any react state that your UI needs must be in the view model.
#### View
1. Views are simple react components (eg: `FooView`).
2. Views usually start by calling the view model hook, eg:
```tsx
const FooView: React.FC<IProps> = (props: IProps) => {
const vm = useFooViewModel();
....
return(
<div>
{vm.somethingUseful}
</div>
);
}
```
3. Views are also allowed to accept the view model as a prop, eg:
```tsx
const FooView: React.FC<IProps> = ({ vm }: IProps) => {
....
return(
<div>
{vm.somethingUseful}
</div>
);
}
```
4. Multiple views can share the same view model if necessary.
### Benefits
1. MVVM forces a separation of concern i.e we will no longer have large react components that have a lot of state and rendering code mixed together. This improves code readability and makes it easier to introduce changes.
2. Introduces the possibility of code reuse. You can reuse an old view model with a new view or vice versa.
3. Adding to the point above, in future you could import element-web view models to your project and supply your own views thus creating something similar to the [hydrogen sdk](https://github.com/element-hq/hydrogen-web/blob/master/doc/SDK.md).
### Example
We started experimenting with MVVM in the redesigned memberlist, you can see the code [here](https://github.com/vector-im/element-web/blob/develop/src/components/views/rooms/MemberList/MemberListView.tsx).

View File

@@ -10,80 +10,58 @@ If you do MVVM right, your view should be dumb i.e it gets data from the view mo
### Practical guidelines for MVVM in element-web
A first documentation and implementation of MVVM was done in [MVVM-v1.md](MVVM-v1.md). This v1 version is now deprecated and this document describes the current implementation.
#### Model
This is anywhere your data or business logic comes from. If your view model is accessing something simple exposed from `matrix-js-sdk`, then the sdk is your model. If you're using something more high level in element-web to get your data/logic (eg: `MemberListStore`), then that becomes your model.
#### View
1. Located in [`shared-components`](https://github.com/element-hq/element-web/tree/develop/packages/shared-components). Develop it in storybook!
2. Views are simple react components (eg: `FooView`).
3. Views use [useSyncExternalStore](https://react.dev/reference/react/useSyncExternalStore) internally where the view model is the external store.
4. Views should define the interface of the view model they expect:
```tsx
// Snapshot is the return type of your view model
interface FooViewSnapshot {
value: string;
}
// To call function on the view model
interface FooViewActions {
doSomething: () => void;
}
// ViewModel is a type defining the methods needed for `useSyncExternalStore`
// https://github.com/element-hq/element-web/blob/develop/packages/shared-components/src/ViewModel.ts
type FooViewModel = ViewModel<FooViewSnapshot> & FooViewActions;
interface FooViewProps {
vm: FooViewModel;
}
function FooView({ vm }: FooViewProps) {
// useViewModel is a helper function that uses useSyncExternalStore under the hood
const { value } = useViewModel(vm);
return (
<button type="button" onClick={() => vm.doSomething()}>
{value}
</button>
);
}
```
5. Multiple views can share the same view model if necessary.
6. A full example is available [here](https://github.com/element-hq/element-web/blob/develop/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.tsx)
#### View Model
1. A View model is a class extending [`BaseViewModel`](https://github.com/element-hq/element-web/blob/develop/src/viewmodels/base/BaseViewModel.ts).
2. Implements the interface defined in the view (e.g `FooViewModel` in the example above).
3. View models define a snapshot type that defines the data the view will consume. The snapshot is immutable and can only be changed by calling `this.snapshot.set(...)` in the view model. This will trigger a re-render in the view.
1. View model is always a custom react hook named like `useFooViewModel()`.
2. The return type of your view model (known as view state) must be defined as a typescript interface:
```ts
interface Props {
propsValue: string;
}
class FooViewModel extends BaseViewModel<FooViewSnapshot, Props> implements FooViewModel {
constructor(props: Props) {
// Call super with initial snapshot
super(props, { value: "initial" });
}
public doSomething() {
// Call this.snapshot.set to update the snapshot
this.snapshot.set({ value: "changed" });
}
inteface FooViewState {
somethingUseful: string;
somethingElse: BarType;
update: () => Promise<void>
...
}
```
3. Any react state that your UI needs must be in the view model.
4. A full example is available [here](https://github.com/element-hq/element-web/blob/develop/src/viewmodels/audio/AudioPlayerViewModel.ts)
#### View
1. Views are simple react components (eg: `FooView`).
2. Views usually start by calling the view model hook, eg:
```tsx
const FooView: React.FC<IProps> = (props: IProps) => {
const vm = useFooViewModel();
....
return(
<div>
{vm.somethingUseful}
</div>
);
}
```
3. Views are also allowed to accept the view model as a prop, eg:
```tsx
const FooView: React.FC<IProps> = ({ vm }: IProps) => {
....
return(
<div>
{vm.somethingUseful}
</div>
);
}
```
4. Multiple views can share the same view model if necessary.
### Benefits
1. MVVM forces a separation of concern i.e we will no longer have large react components that have a lot of state and rendering code mixed together. This improves code readability and makes it easier to introduce changes.
2. Introduces the possibility of code reuse. You can reuse an old view model with a new view or vice versa.
3. Adding to the point above, in future you could import element-web view models to your project and supply your own views thus creating something similar to the [hydrogen sdk](https://github.com/element-hq/hydrogen-web/blob/master/doc/SDK.md).
### Example
We started experimenting with MVVM in the redesigned memberlist, you can see the code [here](https://github.com/vector-im/element-web/blob/develop/src/components/views/rooms/MemberList/MemberListView.tsx).

View File

@@ -407,7 +407,7 @@ The VoIP and Jitsi options are:
If you run your own rageshake server to collect bug reports, the following options may be of interest:
1. `bug_report_endpoint_url`: URL for where to submit rageshake logs to. Rageshakes include feedback submissions and bug reports. When
not present in the config, the app will disable all rageshake functionality. Set to `https://rageshakes.element.io/api/submit` to submit
not present in the config, the app will disable all rageshake functionality. Set to `https://element.io/bugreports/submit` to submit
rageshakes to us, or use your own rageshake server.
2. `uisi_autorageshake_app`: If a user has enabled the "automatically send debug logs on decryption errors" flag, this option will be sent
alongside the rageshake so the rageshake server can filter them by app name. By default, this will be `element-auto-uisi`

View File

@@ -8,64 +8,16 @@ There are some exceptions like when using localhost, which is considered a [secu
## Release tarball
The release tarball contains a pre-built, production-ready version of Element Web that you can deploy to any static web server.
1. Download the latest version from <https://github.com/element-hq/element-web/releases>
1. Untar the tarball on your web server
1. Move (or symlink) the `element-x.x.x` directory to an appropriate name
1. Configure the correct caching headers in your webserver (see below)
1. Configure the app by copying `config.sample.json` to `config.json` and
modifying it. See the [configuration docs](config.md) for details.
1. Enter the URL into your browser and log into Element!
### Installation Steps
1. **Download the latest release**
Download from <https://github.com/element-hq/element-web/releases>
Releases are signed using GPG and the OpenPGP standard. You can verify the signature against the public key at <https://packages.element.io/element-release-key.asc>
2. **Extract the tarball**
```bash
tar -xzf element-v*.tar.gz
```
This creates a directory named `element-x.x.x` containing all the static files.
3. **Deploy to your web server**
Move or symlink the directory to your web server's document root:
```bash
# Example: Move to /var/www/element
sudo mv element-x.x.x /var/www/element
# Or create a symlink for easier version management
sudo ln -s /var/www/element-x.x.x /var/www/element
```
4. **Configure Element Web**
Copy the sample configuration and customize it:
```bash
cd /var/www/element
cp config.sample.json config.json
```
Edit `config.json` to configure your homeserver and other settings. See the [configuration docs](config.md) for details.
5. **Configure your web server**
Set up proper caching headers and security settings. See the [web server configuration examples](#web-server-configuration) below.
6. **Access Element Web**
Navigate to your server's URL (e.g., `https://element.example.com`) and log in!
### Web Server Configuration
Element Web requires specific caching headers to work correctly. The following files **must not be cached** to ensure users always get the latest version:
- `/index.html`
- `/version`
- `/config*.json` (including `config.json` and `config.domain.json`)
Additionally, configure `Cache-Control: no-cache` for `/` to force browsers to revalidate on page load.
Releases are signed using gpg and the OpenPGP standard,
and can be checked against the public key located at <https://packages.element.io/element-release-key.asc>.
## Debian package

View File

@@ -57,7 +57,7 @@ Then you can deploy it to your cluster with something like `kubectl apply -f my-
"https://scalar-staging.vector.im/_matrix/integrations/v1",
"https://scalar-staging.vector.im/api"
],
"bug_report_endpoint_url": "https://rageshakes.element.io/api/submit",
"bug_report_endpoint_url": "https://element.io/bugreports/submit",
"defaultCountryCode": "GB",
"show_labs_settings": false,
"features": { },

View File

@@ -17,7 +17,7 @@
"https://scalar-staging.vector.im/_matrix/integrations/v1",
"https://scalar-staging.vector.im/api"
],
"bug_report_endpoint_url": "https://rageshakes.element.io/api/submit",
"bug_report_endpoint_url": "https://element.io/bugreports/submit",
"uisi_autorageshake_app": "element-auto-uisi",
"show_labs_settings": false,
"room_directory": {

View File

@@ -17,7 +17,7 @@
"https://scalar-staging.vector.im/_matrix/integrations/v1",
"https://scalar-staging.vector.im/api"
],
"bug_report_endpoint_url": "https://rageshakes.element.io/api/submit",
"bug_report_endpoint_url": "https://element.io/bugreports/submit",
"uisi_autorageshake_app": "element-auto-uisi",
"show_labs_settings": true,
"room_directory": {

View File

@@ -17,7 +17,7 @@ const config: Config = {
// This is needed to be able to load dual CJS/ESM WASM packages e.g. rust crypto & matrix-wywiwyg
customExportConditions: ["browser", "node"],
},
testMatch: ["<rootDir>/test/**/*-test.[tj]s?(x)"],
testMatch: ["<rootDir>/test/**/*-test.[tj]s?(x)", "<rootDir>/src/shared-components/**/*.test.[t]s?(x)"],
globalSetup: "<rootDir>/test/globalSetup.ts",
setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"],
setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"],
@@ -40,14 +40,10 @@ const config: Config = {
"^!!raw-loader!.*": "jest-raw-loader",
"recorderWorkletFactory": "<rootDir>/__mocks__/empty.js",
"^fetch-mock$": "<rootDir>/node_modules/fetch-mock",
"counterpart": "<rootDir>/node_modules/counterpart",
},
transformIgnorePatterns: [
"/node_modules/(?!(mime|matrix-js-sdk|uuid|p-retry|is-network-error|react-merge-refs)).+$",
],
transformIgnorePatterns: ["/node_modules/(?!(mime|matrix-js-sdk|uuid|p-retry|is-network-error)).+$"],
collectCoverageFrom: [
"<rootDir>/src/**/*.{js,ts,tsx}",
"<rootDir>/packages/**/*.{js,ts,tsx}",
// getSessionLock is piped into a different JS context via stringification, and the coverage functionality is
// not available in that contest. So, turn off coverage instrumentation for it.
"!<rootDir>/src/utils/SessionLock.ts",

View File

@@ -19,8 +19,6 @@ export default {
"src/hooks/useTimeout.ts",
"src/components/views/elements/InfoTooltip.tsx",
"src/components/views/elements/StyledCheckbox.tsx",
"packages/**/*",
],
ignoreDependencies: [
// Required for `action-validator`

View File

@@ -1,6 +1,6 @@
{
"name": "element-web",
"version": "1.12.3",
"version": "1.11.112",
"description": "Element: the future of secure communication",
"author": "New Vector Ltd.",
"repository": {
@@ -29,7 +29,7 @@
"UserFriendlyError"
],
"scripts": {
"i18n": "matrix-gen-i18n src res packages/shared-components/src && yarn i18n:sort && yarn i18n:lint",
"i18n": "matrix-gen-i18n && yarn i18n:sort && yarn i18n:lint",
"i18n:sort": "jq --sort-keys '.' src/i18n/strings/en_EN.json > src/i18n/strings/en_EN.json.tmp && mv src/i18n/strings/en_EN.json.tmp src/i18n/strings/en_EN.json",
"i18n:lint": "matrix-i18n-lint && prettier --log-level=silent --write src/i18n/strings/ --ignore-path /dev/null",
"i18n:diff": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && yarn i18n && matrix-compare-i18n-files src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json",
@@ -65,25 +65,28 @@
"coverage": "yarn test --coverage",
"analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp",
"update:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js",
"postinstall": "patch-package"
"postinstall": "patch-package",
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build",
"test:storybook": "test-storybook --url http://localhost:6007/",
"test:storybook:ci": "concurrently -k -s first -n \"SB,TEST\" \"yarn storybook --no-open\" \"wait-on tcp:6007 && yarn test-storybook --url http://localhost:6007/ --ci --maxWorkers=2\"",
"test:storybook:update": "playwright-screenshots --entrypoint yarn --with-node-modules && playwright-screenshots --entrypoint /work/node_modules/.bin/test-storybook --with-node-modules --url http://host.docker.internal:6007/ --updateSnapshot"
},
"resolutions": {
"**/pretty-format/react-is": "19.2.0",
"@playwright/test": "1.56.1",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"@types/serve-static": "1.15.10",
"**/pretty-format/react-is": "19.1.1",
"@playwright/test": "1.54.2",
"@types/react": "19.1.13",
"@types/react-dom": "19.1.9",
"oidc-client-ts": "3.3.0",
"jwt-decode": "4.0.0",
"caniuse-lite": "1.0.30001751",
"caniuse-lite": "1.0.30001724",
"testcontainers": "^11.0.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
"wrap-ansi": "npm:wrap-ansi@^7.0.0"
},
"dependencies": {
"@babel/runtime": "^7.12.5",
"@element-hq/element-web-module-api": "1.5.0",
"@element-hq/web-shared-components": "file:packages/shared-components",
"@element-hq/element-web-module-api": "1.4.1",
"@fontsource/inconsolata": "^5",
"@fontsource/inter": "^5",
"@formatjs/intl-segmenter": "^11.5.7",
@@ -95,7 +98,7 @@
"@types/png-chunks-extract": "^1.0.2",
"@vector-im/compound-design-tokens": "^6.0.0",
"@vector-im/compound-web": "^8.1.2",
"@vector-im/matrix-wysiwyg": "2.40.0",
"@vector-im/matrix-wysiwyg": "2.39.0",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
@@ -105,6 +108,7 @@
"browserslist": "^4.23.2",
"classnames": "^2.2.6",
"commonmark": "^0.31.0",
"counterpart": "^0.18.6",
"css-tree": "^3.0.0",
"diff-dom": "^5.0.0",
"diff-match-patch": "^1.0.5",
@@ -112,7 +116,7 @@
"emojibase-regex": "15.3.2",
"escape-html": "^1.0.3",
"file-saver": "^2.0.5",
"filesize": "11.0.13",
"filesize": "11.0.2",
"github-markdown-css": "^5.5.1",
"glob-to-regexp": "^0.4.1",
"highlight.js": "^11.3.1",
@@ -123,7 +127,6 @@
"jsrsasign": "^11.0.0",
"jszip": "^3.7.0",
"katex": "^0.16.0",
"linkify-html": "4.3.2",
"linkify-react": "4.3.2",
"linkify-string": "4.3.2",
"linkifyjs": "4.3.2",
@@ -139,7 +142,7 @@
"opus-recorder": "^8.0.3",
"pako": "^2.0.3",
"png-chunks-extract": "^1.0.0",
"posthog-js": "1.280.1",
"posthog-js": "1.265.1",
"qrcode": "1.5.4",
"re-resizable": "6.11.2",
"react": "^19.0.0",
@@ -147,7 +150,6 @@
"react-blurhash": "^0.3.0",
"react-dom": "^19.0.0",
"react-focus-lock": "^2.5.1",
"react-merge-refs": "^3.0.2",
"react-string-replace": "^1.1.1",
"react-transition-group": "^4.4.1",
"react-virtuoso": "^4.14.0",
@@ -182,13 +184,19 @@
"@babel/preset-typescript": "^7.12.7",
"@babel/runtime": "^7.12.5",
"@casualbot/jest-sonar-reporter": "2.2.7",
"@element-hq/element-call-embedded": "0.16.1",
"@element-hq/element-web-playwright-common": "^2.0.0",
"@element-hq/element-call-embedded": "0.15.0",
"@element-hq/element-web-playwright-common": "^1.4.6",
"@peculiar/webcrypto": "^1.4.3",
"@playwright/test": "^1.50.1",
"@principalstudio/html-webpack-inject-preload": "^1.2.7",
"@rrweb/types": "^2.0.0-alpha.18",
"@sentry/webpack-plugin": "^4.0.0",
"@storybook/react-vite": "^9.1.10",
"@storybook/addon-a11y": "^9.0.18",
"@storybook/addon-designs": "^10.0.1",
"@storybook/addon-docs": "^9.0.12",
"@storybook/icons": "^1.4.0",
"@storybook/react-vite": "^9.0.15",
"@storybook/test-runner": "^0.23.0",
"@stylistic/eslint-plugin": "^5.0.0",
"@svgr/webpack": "^8.0.0",
"@testing-library/dom": "^10.4.0",
@@ -215,9 +223,9 @@
"@types/node-fetch": "^2.6.2",
"@types/pako": "^2.0.0",
"@types/qrcode": "^1.3.5",
"@types/react": "19.2.2",
"@types/react": "19.1.13",
"@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "19.2.2",
"@types/react-dom": "19.1.9",
"@types/react-transition-group": "^4.4.0",
"@types/sanitize-html": "2.16.0",
"@types/sdp-transform": "^2.4.10",
@@ -241,14 +249,15 @@
"eslint": "8.57.1",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-deprecate": "0.8.7",
"eslint-plugin-deprecate": "0.8.5",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jest": "^29.0.0",
"eslint-plugin-jest": "^28.0.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-matrix-org": "^3.0.0",
"eslint-plugin-matrix-org": "^2.0.2",
"eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124",
"eslint-plugin-react-hooks": "^7.0.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-storybook": "^9.0.12",
"eslint-plugin-unicorn": "^56.0.0",
"express": "^5.0.0",
"fake-indexeddb": "^6.0.0",
@@ -261,6 +270,7 @@
"jest": "^29.6.2",
"jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^29.7.0",
"jest-image-snapshot": "^6.5.1",
"jest-mock": "^29.6.2",
"jest-raw-loader": "^1.0.1",
"jsqr": "^1.4.0",
@@ -289,7 +299,7 @@
"rimraf": "^6.0.0",
"semver": "^7.5.2",
"source-map-loader": "^5.0.0",
"storybook": "^9.1.10",
"storybook": "^9.0.12",
"stylelint": "^16.23.0",
"stylelint-config-standard": "^39.0.0",
"stylelint-scss": "^6.0.0",
@@ -299,6 +309,8 @@
"ts-node": "^10.9.1",
"typescript": "5.8.3",
"util": "^0.12.5",
"vite": "^7.0.1",
"vite-plugin-node-polyfills": "^0.24.0",
"web-streams-polyfill": "^4.0.0",
"webpack": "^5.89.0",
"webpack-bundle-analyzer": "^4.8.0",

View File

@@ -1,71 +0,0 @@
/*
Copyright 2025 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.
*/
module.exports = {
root: true,
plugins: ["matrix-org", "eslint-plugin-react-compiler"],
extends: [
"plugin:matrix-org/babel",
"plugin:matrix-org/react",
"plugin:matrix-org/a11y",
"plugin:storybook/recommended",
],
parserOptions: {
project: ["./tsconfig.json"],
},
env: {
browser: true,
node: true,
},
rules: {
// Bind or arrow functions in props causes performance issues (but we
// currently use them in some places).
// It's disabled here, but we should using it sparingly.
"react/jsx-no-bind": "off",
"react/jsx-key": ["error"],
"matrix-org/require-copyright-header": "error",
"react-compiler/react-compiler": "error",
},
overrides: [
{
files: ["src/**/*.{ts,tsx}", "test/**/*.{ts,tsx}"],
extends: ["plugin:matrix-org/typescript", "plugin:matrix-org/react"],
rules: {
"@typescript-eslint/explicit-function-return-type": [
"error",
{
allowExpressions: true,
},
],
// Remove Babel things manually due to override limitations
"@babel/no-invalid-this": ["off"],
// We're okay being explicit at the moment
"@typescript-eslint/no-empty-interface": "off",
// We disable this while we're transitioning
"@typescript-eslint/no-explicit-any": "off",
// We'd rather not do this but we do
"@typescript-eslint/ban-ts-comment": "off",
// We're okay with assertion errors when we ask for them
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-empty-object-type": [
"error",
{
// We do this sometimes to brand interfaces
allowInterfaces: "with-single-extends",
},
],
},
},
],
settings: {
react: {
version: "detect",
},
},
};

View File

@@ -1,2 +0,0 @@
dist/
i18n/i18nKeys.d.ts

View File

@@ -1,21 +0,0 @@
module.exports = {
sourceMaps: true,
presets: [
[
"@babel/preset-env",
{
include: ["@babel/plugin-transform-class-properties"],
},
],
["@babel/preset-typescript", { allowDeclareFields: true }],
"@babel/preset-react",
],
plugins: [
"@babel/plugin-proposal-export-default-from",
"@babel/plugin-transform-numeric-separator",
"@babel/plugin-transform-object-rest-spread",
"@babel/plugin-transform-optional-chaining",
"@babel/plugin-transform-nullish-coalescing-operator",
"@babel/plugin-transform-runtime",
],
};

View File

@@ -1,58 +0,0 @@
/*
Copyright 2025 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 { env } from "process";
import type { Config } from "jest";
const config: Config = {
testEnvironment: "jsdom",
testEnvironmentOptions: {
url: "http://localhost/",
},
testMatch: ["<rootDir>/src/**/*.test.[tj]s?(x)"],
setupFilesAfterEnv: ["<rootDir>/src/test/setupTests.ts"],
moduleNameMapper: {
// Support CSS module
"\\.(module.css)$": "identity-obj-proxy",
"\\.(css|scss|pcss)$": "<rootDir>/__mocks__/cssMock.js",
"\\.(gif|png|ttf|woff2)$": "<rootDir>/__mocks__/imageMock.js",
"\\.svg$": "<rootDir>/__mocks__/svg.js",
"\\$webapp/i18n/languages.json": "<rootDir>/../../__mocks__/languages.json",
"^react$": "<rootDir>/node_modules/react",
"^react-dom$": "<rootDir>/node_modules/react-dom",
"waveWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js",
"context-filter-polyfill": "<rootDir>/__mocks__/empty.js",
"workers/(.+)Factory": "<rootDir>/__mocks__/workerFactoryMock.js",
},
transformIgnorePatterns: [
"/node_modules/(?!(mime|matrix-js-sdk|uuid|p-retry|is-network-error|react-merge-refs)).+$",
],
collectCoverageFrom: [
"<rootDir>/src/**/*.{js,ts,tsx}",
"<rootDir>/packages/**/*.{js,ts,tsx}",
// Coverage chokes on type definition files
"!<rootDir>/src/**/*.d.ts",
],
coverageReporters: ["text-summary", "lcov"],
testResultsProcessor: "@casualbot/jest-sonar-reporter",
prettierPath: null,
moduleDirectories: ["node_modules", "./src/test/utils"],
};
// if we're running under GHA, enable the GHA reporter
if (env["GITHUB_ACTIONS"] !== undefined) {
const reporters: Config["reporters"] = [["github-actions", { silent: false }], "summary"];
// if we're running against the develop branch, also enable the slow test reporter
if (env["GITHUB_REF"] == "refs/heads/develop") {
reporters.push("<rootDir>/../../test/slowReporter.cjs");
}
config.reporters = reporters;
}
export default config;

View File

@@ -1,94 +0,0 @@
{
"name": "@element-hq/web-shared-components",
"version": "0.0.0-test.7",
"description": "Shared components for Element",
"author": "New Vector Ltd.",
"repository": {
"type": "git",
"url": "https://github.com/element-hq/element-web"
},
"exports": {
".": {
"require": {
"style": "./dist/element-web-shared-components.css",
"types": "./dist/element-web-shared-components.d.ts",
"default": "./dist/element-web-shared-components.umd.js"
},
"import": {
"style": "./dist/element-web-shared-components.css",
"types": "./dist/element-web-shared-components.d.ts",
"default": "./dist/element-web-shared-components.mjs"
}
},
"./dist/element-web-shared-components.css": {
"require": "./dist/element-web-shared-components.css",
"import": "./dist/element-web-shared-components.css"
}
},
"types": "dist/element-web-shared-components.d.ts",
"files": [
"dist",
"src",
"LICENSE",
"README.md",
"package.json"
],
"scripts": {
"test": "jest",
"prepare": "patch-package && yarn --cwd ../.. build:res && ts-node scripts/gatherTranslationKeys.ts && vite build",
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build",
"lint": "yarn lint:types && yarn lint:js",
"lint:js": "eslint --max-warnings 0 src && prettier --check .",
"lint:types": "tsc --noEmit --jsx react",
"test:storybook": "test-storybook --url http://localhost:6007/",
"test:storybook:ci": "concurrently -k -s first -n \"SB,TEST\" \"yarn storybook --no-open\" \"wait-on tcp:6007 && yarn test-storybook --url http://localhost:6007/ --ci --maxWorkers=2\"",
"test:storybook:update": "playwright-screenshots --entrypoint /work/node_modules/.bin/test-storybook --with-node-modules --url http://host.docker.internal:6007/ --updateSnapshot"
},
"dependencies": {
"classnames": "^2.5.1",
"counterpart": "^0.18.6",
"lodash": "^4.17.21",
"matrix-web-i18n": "^3.4.0",
"patch-package": "^8.0.1",
"react-merge-refs": "^3.0.2",
"temporal-polyfill": "^0.3.0"
},
"devDependencies": {
"@element-hq/element-web-playwright-common": "^2.0.0",
"@playwright/test": "^1.50.1",
"@storybook/addon-a11y": "^9.1.10",
"@storybook/addon-designs": "^10.0.2",
"@storybook/addon-docs": "^9.1.10",
"@storybook/icons": "^1.6.0",
"@storybook/react-vite": "^9.1.10",
"@storybook/test-runner": "^0.23.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@types/counterpart": "^0.18.4",
"@types/lodash": "^4.17.20",
"@types/react": "^19.2.2",
"concurrently": "^9.2.1",
"eslint": "8",
"eslint-plugin-matrix-org": "^3.0.0",
"eslint-plugin-storybook": "^10.0.0",
"jest": "^30.2.0",
"jest-image-snapshot": "^6.5.1",
"patch-package": "^8.0.1",
"prettier": "^3.6.2",
"storybook": "^9.1.10",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"vite": "^7.1.9",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-node-polyfills": "^0.24.0"
},
"engines": {
"node": ">=20.0.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"peerDependencies": {
"@vector-im/compound-design-tokens": "^6.0.0",
"@vector-im/compound-web": "^8.2.5"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -1,61 +0,0 @@
/*
Copyright 2025 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.
*/
// Gathers all the translation keys from element-web's en_EN.json into a TypeScript type definition file
// that exports a type `TranslationKey` which is a union of all supported translation keys.
// This prevents having to import the json file and make typescript do the work as this results in vite-dts
// generating an import to the json file in the .d.ts which doesn't work at runtime: this way, the type
// gets put into the bundle.
// XXX: It should *not* be in the 'src' directory, being a generated file, but if it isn't then the type
// bundler won't bundle the types and will leave the file as a relative import, which will break.
import * as fs from "fs";
import * as path from "path";
const i18nStringsPath = path.resolve(__dirname, "../../../src/i18n/strings/en_EN.json");
const outPath = path.resolve(__dirname, "../src/i18nKeys.d.ts");
function gatherKeys(obj: any, prefix: string[] = []): string[] {
if (typeof obj !== "object" || obj === null) return [];
let keys: string[] = [];
for (const key of Object.keys(obj)) {
const value = obj[key];
// add the path (for both leaves and intermediates as then we include plurals)
keys.push([...prefix, key].join("|"));
if (typeof value === "object" && value !== null) {
// If the value is an object, recurse
keys = keys.concat(gatherKeys(value, [...prefix, key]));
}
}
return keys;
}
function main() {
const json = JSON.parse(fs.readFileSync(i18nStringsPath, "utf8"));
const keys = gatherKeys(json);
const typeDef =
"/*\n" +
" * Copyright 2025 Element Creations Ltd.\n" +
" *\n" +
" * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial\n" +
" * Please see LICENSE files in the repository root for full details.\n" +
" */\n" +
"\n" +
"// This file is auto-generated by gatherTranslationKeys.ts\n" +
"// Do not edit manually.\n\n" +
"export type TranslationKey =\n" +
keys.map((k) => ` | \"${k}\"`).join("\n") +
";\n";
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, typeDef, "utf8");
console.log(`Wrote ${keys.length} keys to ${outPath}`);
}
if (require.main === module) {
main();
}

View File

@@ -1,11 +0,0 @@
/*
* 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.
*/
.button {
border-radius: 32px !important;
background-color: var(--cpd-color-bg-subtle-primary) !important;
}

View File

@@ -1,8 +0,0 @@
/*
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.
*/
export { TextualEventView, type TextualEventViewSnapshot } from "./TextualEventView";

View File

@@ -1,155 +0,0 @@
/*
* 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 KeyboardEvent } from "react";
import { renderHook } from "jest-matrix-react";
import { useListKeyboardNavigation } from "./useListKeyboardNavigation";
describe("useListKeyDown", () => {
let mockList: HTMLUListElement;
let mockItems: HTMLElement[];
let mockEvent: Partial<KeyboardEvent<HTMLUListElement>>;
beforeEach(() => {
// Create mock DOM elements
mockList = document.createElement("ul");
mockItems = [document.createElement("li"), document.createElement("li"), document.createElement("li")];
// Set up the DOM structure
mockItems.forEach((item, index) => {
item.setAttribute("tabindex", "0");
item.setAttribute("data-testid", `item-${index}`);
mockList.appendChild(item);
});
document.body.appendChild(mockList);
// Mock event object
mockEvent = {
preventDefault: jest.fn(),
key: "",
};
// Mock focus methods
mockItems.forEach((item) => {
item.focus = jest.fn();
item.click = jest.fn();
});
});
afterEach(() => {
document.body.removeChild(mockList);
jest.clearAllMocks();
});
function render(): {
current: {
listRef: React.RefObject<HTMLUListElement | null>;
onKeyDown: React.KeyboardEventHandler<HTMLUListElement>;
onFocus: React.FocusEventHandler<HTMLUListElement>;
};
} {
const { result } = renderHook(() => useListKeyboardNavigation());
result.current.listRef.current = mockList;
return result;
}
it.each([
["Enter", "Enter"],
["Space", " "],
])("should handle %s key to click active element", (name, key) => {
const result = render();
// Mock document.activeElement
Object.defineProperty(document, "activeElement", {
value: mockItems[1],
configurable: true,
});
// Simulate key press
result.current.onKeyDown({
...mockEvent,
key,
} as KeyboardEvent<HTMLUListElement>);
expect(mockItems[1].click).toHaveBeenCalledTimes(1);
expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1);
});
it.each(
// key, finalPosition, startPosition
[
["ArrowDown", 1, 0],
["ArrowUp", 1, 2],
["Home", 0, 1],
["End", 2, 1],
],
)("should handle %s to focus the %inth element", (key, finalPosition, startPosition) => {
const result = render();
mockList.contains = jest.fn().mockReturnValue(true);
Object.defineProperty(document, "activeElement", {
value: mockItems[startPosition],
configurable: true,
});
result.current.onKeyDown({
...mockEvent,
key,
} as KeyboardEvent<HTMLUListElement>);
expect(mockItems[finalPosition].focus).toHaveBeenCalledTimes(1);
expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1);
});
it.each([["ArrowDown"], ["ArrowUp"]])("should not handle %s when active element is not in list", (key) => {
const result = render();
mockList.contains = jest.fn().mockReturnValue(false);
const outsideElement = document.createElement("button");
Object.defineProperty(document, "activeElement", {
value: outsideElement,
configurable: true,
});
result.current.onKeyDown({
...mockEvent,
key,
} as KeyboardEvent<HTMLUListElement>);
// No item should be focused
mockItems.forEach((item) => expect(item.focus).not.toHaveBeenCalled());
expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1);
});
it("should not prevent default for unhandled keys", () => {
const result = render();
result.current.onKeyDown({
...mockEvent,
key: "Tab",
} as KeyboardEvent<HTMLUListElement>);
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
});
it("should focus the first item if list itself is focused", () => {
const result = render();
result.current.onFocus({ target: mockList } as React.FocusEvent<HTMLUListElement>);
expect(mockItems[0].focus).toHaveBeenCalledTimes(1);
});
it("should focus the selected item if list itself is focused", () => {
mockItems[1].setAttribute("aria-selected", "true");
const result = render();
result.current.onFocus({ target: mockList } as React.FocusEvent<HTMLUListElement>);
expect(mockItems[1].focus).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,92 +0,0 @@
/*
* 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 {
useCallback,
useRef,
type RefObject,
type KeyboardEvent,
type KeyboardEventHandler,
type FocusEventHandler,
type FocusEvent,
} from "react";
/**
* A hook that provides keyboard navigation for a list of options.
*/
export function useListKeyboardNavigation(): {
listRef: RefObject<HTMLUListElement | null>;
onKeyDown: KeyboardEventHandler<HTMLUListElement>;
onFocus: FocusEventHandler<HTMLUListElement>;
} {
const listRef = useRef<HTMLUListElement>(null);
const onFocus = useCallback((evt: FocusEvent<HTMLUListElement>) => {
if (!listRef.current) return;
if (evt.target === listRef.current) {
// By default, focus the selected item
let selectedChild = listRef.current?.firstElementChild;
// If there is a selected item, focus that instead
for (const child of listRef.current.children) {
if (child.getAttribute("aria-selected") === "true") {
selectedChild = child;
break;
}
}
(selectedChild as HTMLElement)?.focus();
}
}, []);
const onKeyDown = useCallback((evt: KeyboardEvent<HTMLUListElement>) => {
const { key } = evt;
let handled = false;
switch (key) {
case "Enter":
case " ": {
handled = true;
(document.activeElement as HTMLElement).click();
break;
}
case "ArrowDown": {
handled = true;
const currentFocus = document.activeElement;
if (listRef.current?.contains(currentFocus) && currentFocus) {
(currentFocus.nextElementSibling as HTMLElement)?.focus();
}
break;
}
case "ArrowUp": {
handled = true;
const currentFocus = document.activeElement;
if (listRef.current?.contains(currentFocus) && currentFocus) {
(currentFocus.previousElementSibling as HTMLElement)?.focus();
}
break;
}
case "Home": {
handled = true;
(listRef.current?.firstElementChild as HTMLElement)?.focus();
break;
}
case "End": {
handled = true;
(listRef.current?.lastElementChild as HTMLElement)?.focus();
break;
}
}
if (handled) {
evt.preventDefault();
}
}, []);
return { listRef, onKeyDown, onFocus };
}

View File

@@ -1,37 +0,0 @@
/*
* 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.
*/
// Components
export * from "./audio/AudioPlayerView";
export * from "./audio/Clock";
export * from "./audio/PlayPauseButton";
export * from "./audio/SeekBar";
export * from "./avatar/AvatarWithDetails";
export * from "./event-tiles/TextualEventView";
export * from "./message-body/MediaBody";
export * from "./pill-input/Pill";
export * from "./pill-input/PillInput";
export * from "./rich-list/RichItem";
export * from "./rich-list/RichList";
export * from "./utils/Box";
export * from "./utils/Flex";
// Utils
export * from "./utils/i18n";
export * from "./utils/humanize";
export * from "./utils/DateUtils";
export * from "./utils/numbers";
export * from "./utils/FormattingUtils";
// MVVM
export * from "./viewmodel";
export * from "./useMockedViewModel";
export * from "./useViewModel";
// i18n (we must export this directly in order to not confuse the type bundler, it seems,
// otherwise it will leave it as a relative import rather than bundling it)
export type * from "./i18nKeys.d.ts";

View File

@@ -1,17 +0,0 @@
/*
* 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.
*/
.pill {
background-color: var(--cpd-color-bg-action-primary-rest);
padding: var(--cpd-space-1x) var(--cpd-space-1-5x) var(--cpd-space-1x) var(--cpd-space-1x);
border-radius: 99px;
}
.label {
color: var(--cpd-color-text-on-solid-primary);
font: var(--cpd-font-body-sm-medium);
}

View File

@@ -1,33 +0,0 @@
/*
* 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 React from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Pill } from "./Pill";
const meta = {
title: "PillInput/Pill",
component: Pill,
tags: ["autodocs"],
args: {
label: "Pill",
children: <div style={{ width: 20, height: 20, borderRadius: "100%", backgroundColor: "#ccc" }} />,
onClick: fn(),
},
} satisfies Meta<typeof Pill>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithoutCloseButton: Story = {
args: {
onClick: undefined,
},
};

View File

@@ -1,26 +0,0 @@
/*
* 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 { composeStories } from "@storybook/react-vite";
import { render } from "jest-matrix-react";
import React from "react";
import * as stories from "./Pill.stories";
const { Default, WithoutCloseButton } = composeStories(stories);
describe("Pill", () => {
it("renders the pill", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders the pill without close button", () => {
const { container } = render(<WithoutCloseButton />);
expect(container).toMatchSnapshot();
});
});

View File

@@ -1,62 +0,0 @@
/*
* 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 React, { type MouseEventHandler, type JSX, type PropsWithChildren, type HTMLAttributes, useId } from "react";
import classNames from "classnames";
import { IconButton } from "@vector-im/compound-web";
import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
import { Flex } from "../../utils/Flex";
import styles from "./Pill.module.css";
import { _t } from "../../utils/i18n";
export interface PillProps extends Omit<HTMLAttributes<HTMLDivElement>, "onClick"> {
/**
* The text label to display inside the pill.
*/
label: string;
/**
* Optional click handler for a close button.
* If provided, a close button will be rendered.
*/
onClick?: MouseEventHandler<HTMLButtonElement>;
}
/**
* A pill component that can display a label and an optional close button.
* The badge can also contain child elements, such as icons or avatars.
*
* @example
* ```tsx
* <Pill label="New" onClick={() => console.log("Closed")}>
* <SomeIcon />
* </Pill>
* ```
*/
export function Pill({ className, children, label, onClick, ...props }: PropsWithChildren<PillProps>): JSX.Element {
const id = useId();
return (
<Flex
display="inline-flex"
gap="var(--cpd-space-1-5x)"
align="center"
className={classNames(styles.pill, className)}
{...props}
>
{children}
<span id={id} className={styles.label}>
{label}
</span>
{onClick && (
<IconButton aria-describedby={id} size="16px" onClick={onClick} aria-label={_t("action|delete")}>
<CloseIcon color="var(--cpd-color-icon-tertiary)" />
</IconButton>
)}
</Flex>
);
}

View File

@@ -1,66 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Pill renders the pill 1`] = `
<div>
<div
class="flex pill"
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1-5x); --mx-flex-wrap: nowrap;"
>
<div
style="width: 20px; height: 20px; border-radius: 100%; background-color: rgb(204, 204, 204);"
/>
<span
class="label"
id="_r_0_"
>
Pill
</span>
<button
aria-describedby="_r_0_"
aria-label="Delete"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 16px;"
tabindex="0"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
color="var(--cpd-color-icon-tertiary)"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
/>
</svg>
</div>
</button>
</div>
</div>
`;
exports[`Pill renders the pill without close button 1`] = `
<div>
<div
class="flex pill"
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1-5x); --mx-flex-wrap: nowrap;"
>
<div
style="width: 20px; height: 20px; border-radius: 100%; background-color: rgb(204, 204, 204);"
/>
<span
class="label"
id="_r_1_"
>
Pill
</span>
</div>
</div>
`;

View File

@@ -1,34 +0,0 @@
/*
* 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.
*/
.pillInput {
background-color: var(--cpd-color-bg-subtle-secondary);
border-radius: 20px;
padding: var(--cpd-space-2x) var(--cpd-space-3x) var(--cpd-space-2x) var(--cpd-space-3x);
/* To match pill height in order to avoid the PillInput to grow when a pill is inserted */
min-height: 28px;
}
.pillInput:has(.input:focus) {
outline: var(--cpd-border-width-1) solid var(--cpd-color-gray-1400);
}
.input {
all: unset;
width: 100%;
flex: 1;
color: var(--cpd-color-text-primary);
}
.input::placeholder {
color: var(--cpd-color-text-secondary);
font: var(--cpd-font-body-md-regular);
}
.largerInput {
padding: var(--cpd-space-2x) 0;
}

View File

@@ -1,38 +0,0 @@
/*
* 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 React from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PillInput } from "./PillInput";
const meta = {
title: "PillInput/PillInput",
component: PillInput,
tags: ["autodocs"],
args: {
children: (
<>
<div style={{ minWidth: 162, height: 28, backgroundColor: "#ccc", borderRadius: "99px" }} />
<div style={{ minWidth: 162, height: 28, backgroundColor: "#ccc", borderRadius: "99px" }} />
</>
),
onChange: fn(),
onRemoveChildren: fn(),
inputProps: {
"placeholder": "Type something...",
"aria-label": "pill input",
},
},
} satisfies Meta<typeof PillInput>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const NoChild: Story = { args: { children: undefined } };

View File

@@ -1,43 +0,0 @@
/*
* 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 { render, screen } from "jest-matrix-react";
import React from "react";
import { composeStories } from "@storybook/react-vite";
import userEvent from "@testing-library/user-event";
import * as stories from "./PillInput.stories";
import { PillInput } from "./PillInput";
const { Default, NoChild } = composeStories(stories);
describe("PillInput", () => {
it("renders the pill input", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders only the input without children", () => {
const { container } = render(<NoChild />);
expect(container).toMatchSnapshot();
});
it("calls onRemoveChildren when backspace is pressed and input is empty", async () => {
const user = userEvent.setup();
const mockOnRemoveChildren = jest.fn();
render(<PillInput onRemoveChildren={mockOnRemoveChildren} />);
const input = screen.getByRole("textbox");
// Focus the input and press backspace (input should be empty by default)
await user.click(input);
await user.keyboard("{Backspace}");
expect(mockOnRemoveChildren).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,96 +0,0 @@
/*
* 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 React, {
type PropsWithChildren,
type JSX,
useRef,
type KeyboardEventHandler,
type HTMLAttributes,
type HTMLProps,
Children,
} from "react";
import classNames from "classnames";
import { omit } from "lodash";
import { useMergeRefs } from "react-merge-refs";
import styles from "./PillInput.module.css";
import { Flex } from "../../utils/Flex";
export interface PillInputProps extends HTMLAttributes<HTMLDivElement> {
/**
* Callback for when the user presses backspace on an empty input.
*/
onRemoveChildren?: KeyboardEventHandler;
/**
* Props to pass to the input element.
*/
inputProps?: HTMLProps<HTMLInputElement> & { "data-testid"?: string };
}
/**
* An input component that can contain multiple child elements and an input field.
*
* @example
* ```tsx
* <PillInput>
* <div>Child 1</div>
* <div>Child 2</div>
* </PillInput>
* ```
*/
export function PillInput({
className,
children,
onRemoveChildren,
inputProps,
...props
}: PropsWithChildren<PillInputProps>): JSX.Element {
const inputRef = useRef<HTMLInputElement>(null);
const inputAttributes = omit(inputProps, ["onKeyDown", "ref"]);
const ref = useMergeRefs([inputRef, inputProps?.ref]);
const hasChildren = Children.toArray(children).length > 0;
return (
<Flex
{...props}
gap="var(--cpd-space-1x)"
direction="column"
className={classNames(styles.pillInput, className)}
onClick={(evt) => {
evt.preventDefault();
evt.stopPropagation();
inputRef.current?.focus();
}}
>
{hasChildren && (
<Flex gap="var(--cpd-space-1x)" wrap="wrap" align="center">
{children}
</Flex>
)}
<input
ref={ref}
autoComplete="off"
className={classNames(styles.input, { [styles.largerInput]: hasChildren })}
onKeyDown={(evt) => {
const value = evt.currentTarget.value.trim();
// If the input is empty and the user presses backspace, we call the onRemoveChildren handler
if (evt.key === "Backspace" && !value) {
evt.preventDefault();
onRemoveChildren?.(evt);
return;
}
inputProps?.onKeyDown?.(evt);
}}
{...inputAttributes}
/>
</Flex>
);
}

View File

@@ -1,44 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`PillInput renders only the input without children 1`] = `
<div>
<div
class="flex pillInput"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<input
aria-label="pill input"
autocomplete="off"
class="input"
placeholder="Type something..."
/>
</div>
</div>
`;
exports[`PillInput renders the pill input 1`] = `
<div>
<div
class="flex pillInput"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<div
class="flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: wrap;"
>
<div
style="min-width: 162px; height: 28px; background-color: rgb(204, 204, 204); border-radius: 99px;"
/>
<div
style="min-width: 162px; height: 28px; background-color: rgb(204, 204, 204); border-radius: 99px;"
/>
</div>
<input
aria-label="pill input"
autocomplete="off"
class="input largerInput"
placeholder="Type something..."
/>
</div>
</div>
`;

View File

@@ -1,8 +0,0 @@
/*
* 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.
*/
export { PillInput } from "./PillInput";

View File

@@ -1,76 +0,0 @@
/*
* 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.
*/
.richItem {
/* Remove browser button style */
background: transparent;
border: none;
padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-4x);
width: 100%;
box-sizing: border-box;
cursor: pointer;
text-align: start;
display: grid;
column-gap: var(--cpd-space-3x);
grid-template-columns: max-content 1fr max-content;
grid-template-areas:
"avatar title time"
"avatar description time";
}
.richItem:hover,
.richItem:focus {
background-color: var(--cpd-color-bg-subtle-secondary);
border-radius: 12px;
}
.richItem:not(:last-child) {
border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-gray-300);
}
.avatar {
grid-area: avatar;
align-self: center;
}
.title {
grid-area: title;
font: var(--cpd-font-body-sm-semibold);
color: var(--cpd-color-text-primary);
}
.description {
grid-area: description;
}
.timestamp {
grid-area: time;
align-self: center;
}
.title,
.description {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.description,
.timestamp {
font: var(--cpd-font-body-sm-regular);
color: var(--cpd-color-text-secondary);
}
.checkmark {
grid-area: avatar;
align-self: center;
background-color: var(--cpd-color-icon-accent-primary);
width: 32px;
height: 32px;
border-radius: 100%;
}

View File

@@ -1,64 +0,0 @@
/*
* 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 React from "react";
import { fn } from "storybook/test";
import { RichItem } from "./RichItem";
import type { Meta, StoryFn } from "@storybook/react-vite";
const currentTimestamp = new Date("2025-03-09T12:00:00Z").getTime();
export default {
title: "RichList/RichItem",
component: RichItem,
tags: ["autodocs"],
args: {
avatar: <div style={{ width: 32, height: 32, backgroundColor: "#ccc", borderRadius: "50%" }} />,
title: "Rich Item Title",
description: "This is a description of the rich item.",
timestamp: currentTimestamp,
onClick: fn(),
},
beforeEach: () => {
Date.now = () => new Date("2025-08-01T12:00:00Z").getTime();
},
parameters: {
a11y: {
context: "button",
},
},
} as Meta<typeof RichItem>;
const Template: StoryFn<typeof RichItem> = (args) => (
<ul role="listbox" style={{ all: "unset", listStyle: "none" }}>
<RichItem {...args} />
</ul>
);
export const Default = Template.bind({});
export const Selected = Template.bind({});
Selected.args = {
selected: true,
};
export const WithoutTimestamp = Template.bind({});
WithoutTimestamp.args = {
timestamp: undefined,
};
export const Hover = Template.bind({});
Hover.parameters = { pseudo: { hover: true } };
const TemplateSeparator: StoryFn<typeof RichItem> = (args) => (
<ul role="listbox" style={{ all: "unset", listStyle: "none" }}>
<RichItem {...args} />
<RichItem {...args} />
</ul>
);
export const Separator = TemplateSeparator.bind({});

View File

@@ -1,35 +0,0 @@
/*
* 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 { composeStories } from "@storybook/react-vite";
import { render } from "jest-matrix-react";
import React from "react";
import * as stories from "./RichItem.stories";
const { Default, Selected, WithoutTimestamp } = composeStories(stories);
describe("RichItem", () => {
beforeAll(() => {
jest.useFakeTimers().setSystemTime(new Date("2025-08-01T12:00:00Z"));
});
it("renders the item in default state", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders the item in selected state", () => {
const { container } = render(<Selected />);
expect(container).toMatchSnapshot();
});
it("renders the item without timestamp", () => {
const { container } = render(<WithoutTimestamp />);
expect(container).toMatchSnapshot();
});
});

View File

@@ -1,96 +0,0 @@
/*
* 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 React, { type HTMLAttributes, type JSX, memo } from "react";
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
import styles from "./RichItem.module.css";
import { humanizeTime } from "../../utils/humanize";
import { Flex } from "../../utils/Flex";
export interface RichItemProps extends HTMLAttributes<HTMLLIElement> {
/**
* Avatar to display at the start of the item
*/
avatar: React.ReactNode;
/**
* Title to display at the top of the item
*/
title: string;
/**
* Description to display below the title
*/
description: string;
/**
* Timestamp to display at the end of the item
* The value is humanized (e.g. "5 minutes ago")
*/
timestamp?: number;
/**
* Whether the item is selected
* This will replace the avatar with a checkmark
* @default false
*/
selected?: boolean;
}
/**
* A rich item to display in a list, with an avatar, title, description and optional timestamp.
* If selected, the avatar is replaced with a checkmark.
* A separator is added between items in a list.
*
* @example
* ```tsx
* <RichItem
* avatar={<AvatarComponent />}
* title="Rich Item Title"
* description="This is a description of the rich item."
* timestamp={Date.now() - 5 * 60 * 1000} // 5 minutes ago
* selected={true}
* onClick={() => console.log("Item clicked")}
* />
* ```
*/
export const RichItem = memo(function RichItem({
avatar,
title,
description,
timestamp,
selected,
...props
}: RichItemProps): JSX.Element {
return (
<li
className={styles.richItem}
role="option"
tabIndex={-1}
aria-selected={selected}
aria-label={title}
{...props}
>
{selected ? <Checkmark /> : <Flex className={styles.avatar}>{avatar}</Flex>}
<span className={styles.title}>{title}</span>
<span className={styles.description}>{description}</span>
{timestamp && (
<span role="timer" className={styles.timestamp}>
{humanizeTime(timestamp)}
</span>
)}
</li>
);
});
/**
* A checkmark icon inside a circle, used to indicate selection.
*/
function Checkmark(): JSX.Element {
return (
<Flex align="center" justify="center" aria-hidden="true" className={styles.checkmark}>
<CheckIcon width="24px" height="24px" color="var(--cpd-color-icon-on-solid-primary)" />
</Flex>
);
}

View File

@@ -1,129 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`RichItem renders the item in default state 1`] = `
<div>
<ul
role="listbox"
style="all: unset; list-style: none;"
>
<li
aria-label="Rich Item Title"
class="richItem"
role="option"
tabindex="-1"
>
<div
class="flex avatar"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
/>
</div>
<span
class="title"
>
Rich Item Title
</span>
<span
class="description"
>
This is a description of the rich item.
</span>
<span
class="timestamp"
role="timer"
>
145 days ago
</span>
</li>
</ul>
</div>
`;
exports[`RichItem renders the item in selected state 1`] = `
<div>
<ul
role="listbox"
style="all: unset; list-style: none;"
>
<li
aria-label="Rich Item Title"
aria-selected="true"
class="richItem"
role="option"
tabindex="-1"
>
<div
aria-hidden="true"
class="flex checkmark"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<svg
color="var(--cpd-color-icon-on-solid-primary)"
fill="currentColor"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
<span
class="title"
>
Rich Item Title
</span>
<span
class="description"
>
This is a description of the rich item.
</span>
<span
class="timestamp"
role="timer"
>
145 days ago
</span>
</li>
</ul>
</div>
`;
exports[`RichItem renders the item without timestamp 1`] = `
<div>
<ul
role="listbox"
style="all: unset; list-style: none;"
>
<li
aria-label="Rich Item Title"
class="richItem"
role="option"
tabindex="-1"
>
<div
class="flex avatar"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
/>
</div>
<span
class="title"
>
Rich Item Title
</span>
<span
class="description"
>
This is a description of the rich item.
</span>
</li>
</ul>
</div>
`;

View File

@@ -1,8 +0,0 @@
/*
* 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.
*/
export { RichItem } from "./RichItem";

View File

@@ -1,30 +0,0 @@
/*
* 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.
*/
.richList {
height: inherit;
}
.title {
font: var(--cpd-font-body-sm-semibold);
color: var(--cpd-color-text-secondary);
padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-4x);
}
.content {
width: 100%;
overflow: auto;
/* remove browser default ul padding/margin */
padding: 0;
margin: 0;
}
.empty {
margin-left: var(--cpd-space-6x);
font: var(--cpd-font-body-sm-regular);
color: var(--cpd-color-text-secondary);
}

View File

@@ -1,50 +0,0 @@
/*
* 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 React from "react";
import { RichList } from "./RichList";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RichItem } from "../RichItem";
const avatar = <div style={{ width: 32, height: 32, backgroundColor: "#ccc", borderRadius: "50%" }} />;
const meta = {
title: "RichList/RichList",
component: RichList,
tags: ["autodocs"],
decorators: [
(Story) => (
<div style={{ height: "220px", overflow: "hidden" }}>
<Story />
</div>
),
],
args: {
title: "Rich List Title",
children: (
<>
<RichItem avatar={avatar} title="First Item" description="description" />
<RichItem selected={true} avatar={avatar} title="Second Item" description="description" />
<RichItem avatar={avatar} title="Third Item" description="description" />
<RichItem avatar={avatar} title="Fourth Item" description="description" />
<RichItem avatar={avatar} title="Fifth Item" description="description" />
</>
),
},
} satisfies Meta<typeof RichList>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Empty: Story = {
args: {
isEmpty: true,
children: "No items available",
},
};

View File

@@ -1,26 +0,0 @@
/*
* 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 { composeStories } from "@storybook/react-vite";
import { render } from "jest-matrix-react";
import React from "react";
import * as stories from "./RichList.stories";
const { Default, Empty } = composeStories(stories);
describe("RichItem", () => {
it("renders the list", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders the list with isEmpty=true", () => {
const { container } = render(<Empty />);
expect(container).toMatchSnapshot();
});
});

View File

@@ -1,80 +0,0 @@
/*
* 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 React, { type HTMLProps, type JSX, type PropsWithChildren, useId } from "react";
import classNames from "classnames";
import styles from "./RichList.module.css";
import { Flex } from "../../utils/Flex";
import { useListKeyboardNavigation } from "../../hooks/useListKeyboardNavigation";
export interface RichListProps extends HTMLProps<HTMLDivElement> {
/**
* Title to display at the top of the list
*/
title: string;
/**
* Attributes to pass to the title element
* This can be used to set accessibility attributes like `aria-level` or `role`
* @example
* ```tsx
* <RichList title="My List" titleAttributes={{ role: "heading", "aria-level": 2 }}>
* ```
*/
titleAttributes?: HTMLProps<HTMLSpanElement>;
/**
* Indicates if the list should show an empty state.
* The list renders its children in a span instead of an ul.
*/
isEmpty?: boolean;
}
/**
* A list component with a title and children.
*
* @example
* ```tsx
* <RichList title="My List">
* <RichItem ... />
* <RichItem ... />
* </RichList>
* ```
*/
export function RichList({
children,
title,
className,
titleAttributes,
isEmpty = false,
...props
}: PropsWithChildren<RichListProps>): JSX.Element {
const id = useId();
const { listRef, onKeyDown, onFocus } = useListKeyboardNavigation();
return (
<Flex className={classNames(styles.richList, className)} direction="column" {...props}>
<span id={id} className={styles.title} {...titleAttributes}>
{title}
</span>
{isEmpty ? (
<span className={styles.empty}>{children}</span>
) : (
<ul
ref={listRef}
role="listbox"
className={styles.content}
aria-labelledby={id}
tabIndex={0}
onKeyDown={onKeyDown}
onFocus={onFocus}
>
{children}
</ul>
)}
</Flex>
);
}

View File

@@ -1,189 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`RichItem renders the list 1`] = `
<div>
<div
style="height: 220px; overflow: hidden;"
>
<div
class="flex richList"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
class="title"
id="_r_0_"
>
Rich List Title
</span>
<ul
aria-labelledby="_r_0_"
class="content"
role="listbox"
tabindex="0"
>
<li
aria-label="First Item"
class="richItem"
role="option"
tabindex="-1"
>
<div
class="flex avatar"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
/>
</div>
<span
class="title"
>
First Item
</span>
<span
class="description"
>
description
</span>
</li>
<li
aria-label="Second Item"
aria-selected="true"
class="richItem"
role="option"
tabindex="-1"
>
<div
aria-hidden="true"
class="flex checkmark"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<svg
color="var(--cpd-color-icon-on-solid-primary)"
fill="currentColor"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
<span
class="title"
>
Second Item
</span>
<span
class="description"
>
description
</span>
</li>
<li
aria-label="Third Item"
class="richItem"
role="option"
tabindex="-1"
>
<div
class="flex avatar"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
/>
</div>
<span
class="title"
>
Third Item
</span>
<span
class="description"
>
description
</span>
</li>
<li
aria-label="Fourth Item"
class="richItem"
role="option"
tabindex="-1"
>
<div
class="flex avatar"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
/>
</div>
<span
class="title"
>
Fourth Item
</span>
<span
class="description"
>
description
</span>
</li>
<li
aria-label="Fifth Item"
class="richItem"
role="option"
tabindex="-1"
>
<div
class="flex avatar"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
/>
</div>
<span
class="title"
>
Fifth Item
</span>
<span
class="description"
>
description
</span>
</li>
</ul>
</div>
</div>
</div>
`;
exports[`RichItem renders the list with isEmpty=true 1`] = `
<div>
<div
style="height: 220px; overflow: hidden;"
>
<div
class="flex richList"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
class="title"
id="_r_1_"
>
Rich List Title
</span>
<span
class="empty"
>
No items available
</span>
</div>
</div>
</div>
`;

View File

@@ -1,8 +0,0 @@
/*
* 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.
*/
export { RichList } from "./RichList";

View File

@@ -1,22 +0,0 @@
/*
Copyright 2025 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 fetchMock from "fetch-mock-jest";
import { setLanguage } from "../../src/utils/i18n";
import en from "../../../../src/i18n/strings/en_EN.json";
export function setupLanguageMock(): void {
fetchMock
.get("/i18n/languages.json", {
en: "en_EN.json",
})
.get("end:en_EN.json", en);
}
setupLanguageMock();
setLanguage("en");

View File

@@ -1,47 +0,0 @@
/*
Copyright 2025 Element Creations Ltd.
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.
*/
// Copied from element-web/test/test-utils because, seemingly, if we
// set that as the modules directory to use it directly, it fails to
// actually put the right thing in the context somehow.
import React, { type ReactElement } from "react";
// eslint-disable-next-line no-restricted-imports
import { render, type RenderOptions } from "@testing-library/react";
import { TooltipProvider } from "@vector-im/compound-web";
const wrapWithTooltipProvider = (Wrapper: RenderOptions["wrapper"]) => {
return ({ children }: { children: React.ReactNode }) => {
if (Wrapper) {
return (
<Wrapper>
<TooltipProvider>{children}</TooltipProvider>
</Wrapper>
);
} else {
return <TooltipProvider>{children}</TooltipProvider>;
}
};
};
const customRender = (ui: ReactElement, options: RenderOptions = {}): ReturnType<typeof render> => {
return render(ui, {
...options,
wrapper: wrapWithTooltipProvider(options?.wrapper) as RenderOptions["wrapper"],
}) as ReturnType<typeof render>;
};
// eslint-disable-next-line no-restricted-imports
export * from "@testing-library/react";
/**
* This custom render function wraps your component with a TooltipProvider.
* See https://testing-library.com/docs/react-testing-library/setup/#custom-render
*/
export { customRender as render };

View File

@@ -1,25 +0,0 @@
/*
* 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 { useMemo } from "react";
import { MockViewModel, type ViewModel } from "./viewmodel";
/**
* Hook helper to return a mocked view model created with the given snapshot and actions.
* This is useful for testing components in isolation with a mocked view model and allows to use primitive types in stories.
*
* @param snapshot
* @param actions
*/
export function useMockedViewModel<S, A>(snapshot: S, actions: A): ViewModel<S> & A {
return useMemo(() => {
const vm = new MockViewModel<S>(snapshot);
Object.assign(vm, actions);
return vm as unknown as ViewModel<S> & A;
}, [snapshot, actions]);
}

View File

@@ -1,37 +0,0 @@
/*
* 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 { humanizeTime } from "./humanize";
describe("humanizeTime", () => {
const now = new Date("2025-08-01T12:00:00Z").getTime();
beforeAll(() => {
jest.useFakeTimers().setSystemTime(now);
});
it.each([
// Past
["returns 'a few seconds ago' for <15s ago", now - 5000, "a few seconds ago"],
["returns 'about a minute ago' for <75s ago", now - 60000, "about a minute ago"],
["returns '20 minutes ago' for <45min ago", now - 20 * 60000, "20 minutes ago"],
["returns 'about an hour ago' for <75min ago", now - 70 * 60000, "about an hour ago"],
["returns '5 hours ago' for <23h ago", now - 5 * 3600000, "5 hours ago"],
["returns 'about a day ago' for <26h ago", now - 25 * 3600000, "about a day ago"],
["returns '3 days ago' for >26h ago", now - 3 * 24 * 3600000, "3 days ago"],
// Future
["returns 'a few seconds from now' for <15s ahead", now + 5000, "a few seconds from now"],
["returns 'about a minute from now' for <75s ahead", now + 60000, "about a minute from now"],
["returns '20 minutes from now' for <45min ahead", now + 20 * 60000, "20 minutes from now"],
["returns 'about an hour from now' for <75min ahead", now + 70 * 60000, "about an hour from now"],
["returns '5 hours from now' for <23h ahead", now + 5 * 3600000, "5 hours from now"],
["returns 'about a day from now' for <26h ahead", now + 25 * 3600000, "about a day from now"],
["returns '3 days from now' for >26h ahead", now + 3 * 24 * 3600000, "3 days from now"],
])("%s", (_, date, expected) => {
expect(humanizeTime(date)).toBe(expected);
});
});

View File

@@ -1,46 +0,0 @@
/*
* Copyright 2025 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 counterpart from "counterpart";
import { registerTranslations, setMissingEntryGenerator, getLocale, setLocale } from "./i18n";
describe("i18n utils", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("should wrap registerTranslations", () => {
jest.spyOn(counterpart, "registerTranslations");
registerTranslations("en", { test: "This is a test" });
expect(counterpart.registerTranslations).toHaveBeenCalledWith("en", { test: "This is a test" });
});
it("should wrap setMissingEntryGenerator", () => {
jest.spyOn(counterpart, "setMissingEntryGenerator");
const dummyFn = jest.fn();
setMissingEntryGenerator(dummyFn);
expect(counterpart.setMissingEntryGenerator).toHaveBeenCalledWith(dummyFn);
});
it("should wrap getLocale", () => {
jest.spyOn(counterpart, "getLocale");
getLocale();
expect(counterpart.getLocale).toHaveBeenCalled();
});
it("should wrap setLocale", () => {
jest.spyOn(counterpart, "setLocale");
setLocale("en");
expect(counterpart.setLocale).toHaveBeenCalledWith("en");
});
});

View File

@@ -1,13 +0,0 @@
/*
* 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.
*/
export * from "./BaseViewModel";
export * from "./Disposables";
export * from "./Snapshot";
export * from "./ViewModelSubscriptions";
export type * from "./ViewModel";
export * from "./MockViewModel";

View File

@@ -1,31 +0,0 @@
{
"compilerOptions": {
"allowImportingTsExtensions": true,
"experimentalDecorators": false,
"emitDecoratorMetadata": false,
"resolveJsonModule": true,
"esModuleInterop": true,
"useDefineForClassFields": true,
"module": "es2022",
"moduleResolution": "node",
"target": "es2022",
"noUnusedLocals": true,
"sourceMap": false,
"outDir": "./lib",
"declaration": true,
"jsx": "react",
"lib": ["es2022", "es2024.promise", "dom", "dom.iterable"],
"strict": true,
"paths": {
"jest-matrix-react": ["./src/test/utils/jest-matrix-react"],
"rollup/parseAst": ["./node_modules/rollup/dist/parseAst"]
}
},
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
"ts-node": {
"files": true,
"moduleTypes": {
"*": "cjs"
}
}
}

View File

@@ -1,54 +0,0 @@
/*
*
* 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 { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, "src/index.ts"),
name: "Element Web Shared Components",
// the proper extensions will be added
fileName: "element-web-shared-components",
},
outDir: "dist",
rollupOptions: {
// make sure to externalize deps that shouldn't be bundled
// into your library
external: ["react", "react-dom", "@vector-im/compound-design-tokens", "@vector-im/compound-web"],
output: {
// Provide global variables to use in the UMD build
// for externalized deps
globals: {
"react": "react",
"react-dom": "ReactDom",
},
},
},
},
resolve: {
alias: {
// Alias used by i18n.tsx
$webapp: resolve(__dirname, "..", "..", "webapp"),
},
},
plugins: [
dts({
rollupTypes: true,
include: ["src/**/*.{ts,tsx}"],
exclude: ["src/**/*.test.{ts,tsx}"],
copyDtsFiles: true,
}),
],
});

File diff suppressed because it is too large Load Diff

View File

@@ -92,7 +92,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
css: `
/* The timestamp is of inconsistent width depending on the time the test runs at */
.mx_MessageTimestamp {
visibility: hidden;
display: none !important;
}
/* The MAB showing up on hover is not needed for the test */
.mx_MessageActionBar {

View File

@@ -124,12 +124,11 @@ test.describe("HTML Export", () => {
const zip = await extractZipFileToPath(zipPath, dirPath);
await page.goto(`file://${dirPath}/${Object.keys(zip.files)[0]}/messages.html`);
await expect(page).toMatchScreenshot("html-export.png", {
mask: [page.locator(".mx_TimelineSeparator")],
css: `
.mx_MessageTimestamp {
visibility: hidden;
}
`,
mask: [
// We need to mask the whole thing because the width of the time part changes
page.locator(".mx_TimelineSeparator"),
page.locator(".mx_MessageTimestamp"),
],
});
},
);

View File

@@ -76,57 +76,6 @@ test.describe("Composer", () => {
await expect(page.locator(".mx_EventTile_body", { hasText: "😇" })).toBeVisible();
});
test.describe("render emoji picker with larger viewport height", async () => {
test.use({ viewport: { width: 1280, height: 720 } });
test("render emoji picker", { tag: "@screenshot" }, async ({ page, app }) => {
await app.getComposer(false).getByRole("button", { name: "Emoji" }).click();
await expect(page.getByTestId("mx_EmojiPicker")).toMatchScreenshot("emoji-picker.png");
});
});
test.describe("render emoji picker with small viewport height", async () => {
test.use({ viewport: { width: 1280, height: 360 } });
test("render emoji picker", { tag: "@screenshot" }, async ({ page, app }) => {
await app.getComposer(false).getByRole("button", { name: "Emoji" }).click();
await expect(page.getByTestId("mx_EmojiPicker")).toMatchScreenshot("emoji-picker-small.png");
});
});
test("should have focus lock in emoji picker", async ({ page, app }) => {
const emojiButton = app.getComposer(false).getByRole("button", { name: "Emoji" });
// Open emoji picker by clicking the button
await emojiButton.click();
// Wait for emoji picker to be visible
const emojiPicker = page.getByTestId("mx_EmojiPicker");
await expect(emojiPicker).toBeVisible();
// Get initial focused element (should be search input)
const searchInput = emojiPicker.getByRole("textbox", { name: "Search" });
await expect(searchInput).toBeFocused();
// Try to tab multiple times - focus should stay within emoji picker
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
// Verify we're still within the emoji picker (not back to composer)
const focusedElement = await page.evaluate(() => document.activeElement?.closest(".mx_EmojiPicker"));
expect(focusedElement).not.toBeNull();
// Close with Escape key
await page.keyboard.press("Escape");
// Verify emoji picker is closed
await expect(emojiPicker).not.toBeVisible();
// Verify focus returns to emoji button
await expect(emojiButton).toBeFocused();
});
test.describe("when Control+Enter is required to send", () => {
test.beforeEach(async ({ app }) => {
await app.settings.setValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true);

View File

@@ -24,8 +24,10 @@ const startDMWithBob = async (page: Page, bob: Bot) => {
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
await page.getByRole("menuitem", { name: "Start chat" }).click();
await page.getByTestId("invite-dialog-input").fill(bob.credentials.userId);
await page.getByRole("option", { name: bob.credentials.displayName }).click();
await expect(page.getByTestId("invite-dialog-input-wrapper").getByText("Bob")).toBeVisible();
await page.locator(".mx_InviteDialog_tile_nameStack_name").getByText("Bob").click();
await expect(
page.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText("Bob"),
).toBeVisible();
await page.getByRole("button", { name: "Go" }).click();
};
@@ -146,29 +148,6 @@ test.describe("Cryptography", function () {
}).toPass();
});
// When the user resets their identity, key storage also gets enabled.
// Check that the toggle updates to show the correct state.
test("Key backup status updates after resetting identity", async ({ page, app, user: aliceCredentials }) => {
await app.client.bootstrapCrossSigning(aliceCredentials);
const encryptionTab = await app.settings.openUserSettings("Encryption");
const keyStorageToggle = encryptionTab.getByRole("switch", { name: "Allow key storage" });
// Check that key storage starts off as disabled
expect(await keyStorageToggle.isChecked()).toBe(false);
// Find "the Reset cryptographic identity" button
await encryptionTab.getByRole("button", { name: "Reset cryptographic identity" }).click();
// Confirm
await encryptionTab.getByRole("button", { name: "Continue" }).click();
// Enter the password
await page.getByPlaceholder("Password").fill(aliceCredentials.password);
await page.getByRole("button", { name: "Continue" }).click();
// Key storage should now be enabled
expect(await keyStorageToggle.isChecked()).toBe(true);
});
test(
"creating a DM should work, being e2e-encrypted / user verification",
{ tag: "@screenshot" },

View File

@@ -146,8 +146,8 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
);
// Confirm that the bot user scanned successfully
await expect(infoDialog.getByText("Confirm that you see a green shield on your other device")).toBeVisible();
await infoDialog.getByRole("button", { name: "Yes, I see a green shield" }).click();
await expect(infoDialog.getByText("Almost there! Is your other device showing the same shield?")).toBeVisible();
await infoDialog.getByRole("button", { name: "Yes" }).click();
await infoDialog.getByRole("button", { name: "Got it" }).click();
// wait for the bot to see we have finished
@@ -201,30 +201,6 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
await enterRecoveryKeyAndCheckVerified(page, app, recoveryKey);
});
test("After cancelling verify with another device, I can try again #29882", async ({ page, app, credentials }) => {
// Regression test for https://github.com/element-hq/element-web/issues/29882
// Log in without verifying
await logIntoElement(page, credentials);
const authPage = page.locator(".mx_AuthPage");
await authPage.getByRole("button", { name: "Skip verification for now" }).click();
await authPage.getByRole("button", { name: "I'll verify later" }).click();
await page.waitForSelector(".mx_MatrixChat");
// Start to verify with "Use another device" but cancel
const settings = await app.settings.openUserSettings("Encryption");
await settings.getByRole("button", { name: "Verify this device" }).click();
await page.getByRole("button", { name: "Use another device" }).click();
await page.locator("#mx_Dialog_Container").getByRole("button", { name: "Close dialog" }).click();
// Start again
await settings.getByRole("button", { name: "Verify this device" }).click();
// We should be offered to use another device again.
// (In the bug, we were immediately told that verification has been cancelled.)
await expect(page.getByRole("button", { name: "Use another device" })).toBeVisible();
});
/** Helper for the three tests above which verify by recovery key */
async function enterRecoveryKeyAndCheckVerified(page: Page, app: ElementAppPage, recoveryKey: string) {
await page.getByRole("button", { name: "Use recovery key" }).click();
@@ -270,7 +246,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
// it should contain the device ID of the requesting device
await expect(toast.getByText(`${aliceBotClient.credentials.deviceId} from `)).toBeVisible();
// Accept
await toast.getByRole("button", { name: "Start verification" }).click();
await toast.getByRole("button", { name: "Verify Session" }).click();
/* Click 'Start' to start SAS verification */
await page.getByRole("button", { name: "Start" }).click();
@@ -285,7 +261,10 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
/* And we're all done! */
const infoDialog = page.locator(".mx_InfoDialog");
await infoDialog.getByRole("button", { name: "They match" }).click();
await expect(infoDialog.getByText("Device verified")).toBeVisible();
// We don't assert the full string as the device name is unset on Synapse but set to the user ID on Dendrite
await expect(infoDialog.getByText(`You've successfully verified`)).toContainText(
`(${aliceBotClient.credentials.deviceId})`,
);
await infoDialog.getByRole("button", { name: "Got it" }).click();
});
});

View File

@@ -1,140 +0,0 @@
/*
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 { createNewInstance } from "@element-hq/element-web-playwright-common";
import { expect, test } from "../../element-web-test";
import { ElementAppPage } from "../../pages/ElementAppPage";
import { createRoom, sendMessageInCurrentRoom } from "./utils";
test.use({
displayName: "Alice",
labsFlags: ["feature_share_history_on_invite"],
});
/** Tests for MSC4268: encrypted history sharing */
test.describe("History sharing", function () {
test(
"We should share history when sending invites",
{ tag: "@screenshot" },
async (
{ labsFlags, browser, page: alicePage, user: aliceCredentials, app: aliceElementApp, homeserver },
testInfo,
) => {
// In this test, Alice creates an encrypted room and sends an event;
// we then invite Bob, and ensure Bob can see the content.
await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials);
// Register a second user, and open it in a second instance of the app
const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "Bob");
const bobPage = await createNewInstance(browser, bobCredentials, {}, labsFlags);
const bobElementApp = new ElementAppPage(bobPage);
await bobElementApp.client.bootstrapCrossSigning(bobCredentials);
// Create the room and send a message
await createRoom(alicePage, "TestRoom", true);
await sendMessageInCurrentRoom(alicePage, "A message from Alice");
// Send the invite to Bob
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
// Bob accepts the invite
await bobPage.getByRole("option", { name: "TestRoom" }).click();
await bobPage.getByRole("button", { name: "Accept" }).click();
// Bob should now be able to decrypt the event
await expect(bobPage.getByText("A message from Alice")).toBeVisible();
const mask = [bobPage.locator(".mx_MessageTimestamp")];
await expect(bobPage.locator(".mx_RoomView_body")).toMatchScreenshot("shared-history-invite-accepted.png", {
mask,
});
},
);
test("Messages sent when we believed the room history was unshared should not be visible", async ({
labsFlags,
browser,
page: alicePage,
user: aliceCredentials,
app: aliceElementApp,
homeserver,
}, testInfo) => {
test.setTimeout(60000);
// In this test:
// 1. Alice creates an encrypted room with Bob.
// 2. She sets the history visibility to "shared", but Bob doesn't receive the memo
// 3. Bob sends a message
// 4. Alice invites Charlie
// 5. Charlie can't see the message.
await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials);
await createRoom(alicePage, "TestRoom", true);
// Register a second user, and open it in a second instance of the app
const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "Bob");
const bobPage = await createNewInstance(browser, bobCredentials, {}, labsFlags);
const bobElementApp = new ElementAppPage(bobPage);
await bobElementApp.client.bootstrapCrossSigning(bobCredentials);
// ... and a third
const charlieCredentials = await homeserver.registerUser(
`user_${testInfo.testId}_charlie`,
"password",
"Charlie",
);
const charliePage = await createNewInstance(browser, charlieCredentials, {}, labsFlags);
const charlieElementApp = new ElementAppPage(charliePage);
await charlieElementApp.client.bootstrapCrossSigning(charlieCredentials);
// Alice invites Bob, and Bob accepts
const roomId = await aliceElementApp.getCurrentRoomIdFromUrl();
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
await bobPage.getByRole("option", { name: "TestRoom" }).click();
await bobPage.getByRole("button", { name: "Accept" }).click();
// Bob sends a message with "shared" visibility
await sendMessageInCurrentRoom(bobPage, "Message1: 'shared' visibility");
await expect(alicePage.getByText("Message1")).toBeVisible();
// Alice sets the history visibility to "joined"
await aliceElementApp.client.sendStateEvent(roomId, "m.room.history_visibility", {
history_visibility: "joined",
});
await expect(
bobPage.getByText(
"Alice made future room history visible to all room members, from the point they joined.",
),
).toBeVisible();
// Bob stops syncing, and sends a message with "joined" visibility.
// (Stopping syncing *before* sending the message means that the active sync will be flushed by sending the
// message, so that Alice's change to the history viz below won't be seen by Bob.)
await bobPage.route(`**/sync*`, (route) => route.fulfill({}));
await sendMessageInCurrentRoom(bobPage, "Message2: 'joined' visibility");
await expect(alicePage.getByText("Message2")).toBeVisible();
// Alice changes the history viz, but Bob doesn't receive the memo
await aliceElementApp.client.sendStateEvent(roomId, "m.room.history_visibility", {
history_visibility: "shared",
});
await sendMessageInCurrentRoom(bobPage, "Message3: 'shared' visibility, but Bob thinks it is still 'joined'");
// Alice now invites Charlie
await aliceElementApp.inviteUserToCurrentRoom(charlieCredentials.userId);
await charliePage.getByRole("option", { name: "TestRoom" }).click();
await charliePage.getByRole("button", { name: "Accept" }).click();
// Message1 should be visible
// Message2 should be invisible
// Message3 should be undecryptable
await expect(charliePage.getByText("Message1")).toBeVisible();
await expect(charliePage.getByText("You don't have access to this message")).toBeVisible();
});
});

View File

@@ -138,11 +138,7 @@ test.describe("Editing", () => {
// Take a snapshot of the dialog
await expect(dialog).toMatchScreenshot("message-edit-history-dialog.png", {
css: `
.mx_MessageTimestamp {
visibility: hidden;
}
`,
mask: [page.locator(".mx_MessageTimestamp")],
});
{

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