Merge branch 'develop' into staging

# Conflicts:
#	package.json
#	yarn.lock
This commit is contained in:
Michael Telatynski 2024-10-29 12:49:13 +00:00
commit 1d49a46dd2
No known key found for this signature in database
GPG key ID: A2B008A5F49F5D0D
3258 changed files with 585983 additions and 2834 deletions

View file

@ -7,3 +7,6 @@ karma-reports/
.idea/ .idea/
.tmp/ .tmp/
config.json* config.json*
# Exclude the playwright directory as much as we can as the snapshots are huge and we bind mount it in
playwright/
!playwright/docker-entrypoint.sh

View file

@ -1,16 +1,8 @@
# Copyright 2024 New Vector Ltd.
# Copyright 2017 Aviral Dasgupta # Copyright 2017 Aviral Dasgupta
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
# you may not use this file except in compliance with the License. # Please see LICENSE files in the repository root for full details.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
root = true root = true
@ -26,4 +18,7 @@ trim_trailing_whitespace = true
indent_size = 4 indent_size = 4
[package.json] [package.json]
indent_size = 2 indent_size = 4
[*.tsx.snap]
trim_trailing_whitespace = false

View file

@ -1,4 +1,8 @@
src/vector/modernizr.js src/vector/modernizr.js
test/end-to-end-tests/node_modules/
test/end-to-end-tests/element/
test/end-to-end-tests/synapse/
test/end-to-end-tests/lib/
# Legacy skinning file that some people might still have # Legacy skinning file that some people might still have
src/component-index.js src/component-index.js
# Auto-generated file # Auto-generated file

View file

@ -45,24 +45,12 @@ module.exports = {
name: "matrix-js-sdk/src/index", name: "matrix-js-sdk/src/index",
message: "Please use matrix-js-sdk/src/matrix instead", message: "Please use matrix-js-sdk/src/matrix instead",
}, },
{
name: "matrix-react-sdk",
message: "Please use matrix-react-sdk/src/index instead",
},
{
name: "matrix-react-sdk/",
message: "Please use matrix-react-sdk/src/index instead",
},
], ],
patterns: [ patterns: [
{ {
group: ["matrix-js-sdk/lib", "matrix-js-sdk/lib/", "matrix-js-sdk/lib/**"], group: ["matrix-js-sdk/lib", "matrix-js-sdk/lib/", "matrix-js-sdk/lib/**"],
message: "Please use matrix-js-sdk/src/* instead", message: "Please use matrix-js-sdk/src/* instead",
}, },
{
group: ["matrix-react-sdk/lib", "matrix-react-sdk/lib/", "matrix-react-sdk/lib/**"],
message: "Please use matrix-react-sdk/src/* instead",
},
], ],
}, },
], ],

View file

@ -1,6 +1,6 @@
module.exports = { module.exports = {
plugins: ["matrix-org"], plugins: ["matrix-org"],
extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react"], extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"],
parserOptions: { parserOptions: {
project: ["./tsconfig.json"], project: ["./tsconfig.json"],
}, },
@ -8,88 +8,283 @@ module.exports = {
browser: true, browser: true,
node: true, node: true,
}, },
globals: {
LANGUAGES_FILE: "readonly",
},
rules: { rules: {
// Things we do that break the ideal style // Things we do that break the ideal style
quotes: "off", "no-constant-condition": "off",
}, "prefer-promise-reject-errors": "off",
settings: { "no-async-promise-executor": "off",
react: { "no-extra-boolean-cast": "off",
version: "detect",
}, // 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"],
"no-restricted-properties": [
"error",
...buildRestrictedPropertiesOptions(
["window.innerHeight", "window.innerWidth", "window.visualViewport"],
"Use UIStore to access window dimensions instead.",
),
...buildRestrictedPropertiesOptions(
["*.mxcUrlToHttp", "*.getHttpUriForMxc"],
"Use Media helper instead to centralise access for customisation.",
),
...buildRestrictedPropertiesOptions(["window.setImmediate"], "Use setTimeout instead."),
],
"no-restricted-globals": [
"error",
{
name: "setImmediate",
message: "Use setTimeout instead.",
},
],
"import/no-duplicates": ["error"],
// Ban matrix-js-sdk/src imports in favour of matrix-js-sdk/src/matrix imports to prevent unleashing hell.
// Ban compound-design-tokens raw svg imports in favour of their React component counterparts
"no-restricted-imports": [
"error",
{
paths: [
{
name: "@testing-library/react",
message: "Please use jest-matrix-react instead",
},
{
name: "matrix-js-sdk",
message: "Please use matrix-js-sdk/src/matrix instead",
},
{
name: "matrix-js-sdk/",
message: "Please use matrix-js-sdk/src/matrix instead",
},
{
name: "matrix-js-sdk/src",
message: "Please use matrix-js-sdk/src/matrix instead",
},
{
name: "matrix-js-sdk/src/",
message: "Please use matrix-js-sdk/src/matrix instead",
},
{
name: "matrix-js-sdk/src/index",
message: "Please use matrix-js-sdk/src/matrix instead",
},
{
name: "emojibase-regex",
message:
"This regex doesn't actually test for emoji. See the docs at https://emojibase.dev/docs/regex/ and prefer our own EMOJI_REGEX from HtmlUtils.",
},
],
patterns: [
{
group: [
"matrix-js-sdk/src/**",
"!matrix-js-sdk/src/matrix",
"!matrix-js-sdk/src/crypto-api",
"!matrix-js-sdk/src/types",
"!matrix-js-sdk/src/testing",
"!matrix-js-sdk/src/utils/**",
"matrix-js-sdk/src/utils/internal/**",
"matrix-js-sdk/lib",
"matrix-js-sdk/lib/",
"matrix-js-sdk/lib/**",
// XXX: Temporarily allow these as they are not available via the main export
"!matrix-js-sdk/src/logger",
"!matrix-js-sdk/src/errors",
"!matrix-js-sdk/src/utils",
"!matrix-js-sdk/src/version-support",
"!matrix-js-sdk/src/randomstring",
"!matrix-js-sdk/src/sliding-sync",
"!matrix-js-sdk/src/browser-index",
"!matrix-js-sdk/src/feature",
"!matrix-js-sdk/src/NamespacedValue",
"!matrix-js-sdk/src/ReEmitter",
"!matrix-js-sdk/src/event-mapper",
"!matrix-js-sdk/src/interactive-auth",
"!matrix-js-sdk/src/secret-storage",
"!matrix-js-sdk/src/room-hierarchy",
"!matrix-js-sdk/src/rendezvous",
"!matrix-js-sdk/src/indexeddb-worker",
"!matrix-js-sdk/src/pushprocessor",
"!matrix-js-sdk/src/extensible_events_v1",
"!matrix-js-sdk/src/extensible_events_v1/PollStartEvent",
"!matrix-js-sdk/src/extensible_events_v1/PollResponseEvent",
"!matrix-js-sdk/src/extensible_events_v1/PollEndEvent",
"!matrix-js-sdk/src/extensible_events_v1/InvalidEventError",
"!matrix-js-sdk/src/crypto",
"!matrix-js-sdk/src/crypto/keybackup",
"!matrix-js-sdk/src/crypto/deviceinfo",
"!matrix-js-sdk/src/crypto/dehydration",
"!matrix-js-sdk/src/oidc",
"!matrix-js-sdk/src/oidc/discovery",
"!matrix-js-sdk/src/oidc/authorize",
"!matrix-js-sdk/src/oidc/validate",
"!matrix-js-sdk/src/oidc/error",
"!matrix-js-sdk/src/oidc/register",
"!matrix-js-sdk/src/webrtc",
"!matrix-js-sdk/src/webrtc/call",
"!matrix-js-sdk/src/webrtc/callFeed",
"!matrix-js-sdk/src/webrtc/mediaHandler",
"!matrix-js-sdk/src/webrtc/callEventTypes",
"!matrix-js-sdk/src/webrtc/callEventHandler",
"!matrix-js-sdk/src/webrtc/groupCallEventHandler",
"!matrix-js-sdk/src/models",
"!matrix-js-sdk/src/models/read-receipt",
"!matrix-js-sdk/src/models/relations-container",
"!matrix-js-sdk/src/models/related-relations",
"!matrix-js-sdk/src/matrixrtc",
],
message: "Please use matrix-js-sdk/src/matrix instead",
},
{
group: ["emojibase-regex/emoji*"],
message:
"This regex doesn't actually test for emoji. See the docs at https://emojibase.dev/docs/regex/ and prefer our own EMOJI_REGEX from HtmlUtils.",
},
{
group: ["@vector-im/compound-design-tokens/icons/*"],
message: "Please use @vector-im/compound-design-tokens/assets/web/icons/* instead",
},
],
},
],
// There are too many a11y violations to fix at once
// Turn violated rules off until they are fixed
"jsx-a11y/aria-activedescendant-has-tabindex": "off",
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/interactive-supports-focus": "off",
"jsx-a11y/media-has-caption": "off",
"jsx-a11y/mouse-events-have-key-events": "off",
"jsx-a11y/no-autofocus": "off",
"jsx-a11y/no-noninteractive-element-interactions": "off",
"jsx-a11y/no-noninteractive-element-to-interactive-role": "off",
"jsx-a11y/no-noninteractive-tabindex": "off",
"jsx-a11y/no-static-element-interactions": "off",
"jsx-a11y/role-supports-aria-props": "off",
"matrix-org/require-copyright-header": "error",
}, },
overrides: [ overrides: [
{ {
files: ["src/**/*.{ts,tsx}", "test/**/*.{ts,tsx}", "scripts/*.ts"], files: ["src/**/*.{ts,tsx}", "test/**/*.{ts,tsx}", "playwright/**/*.ts"],
extends: ["plugin:matrix-org/typescript", "plugin:matrix-org/react"], extends: ["plugin:matrix-org/typescript", "plugin:matrix-org/react"],
// NOTE: These rules are frozen and new rules should not be added here.
// New changes belong in https://github.com/matrix-org/eslint-plugin-matrix-org/
rules: { rules: {
// Things we do that break the ideal style "@typescript-eslint/explicit-function-return-type": [
"prefer-promise-reject-errors": "off",
"quotes": "off",
// We disable this while we're transitioning
"@typescript-eslint/no-explicit-any": "off",
// We're okay with assertion errors when we ask for them
"@typescript-eslint/no-non-null-assertion": "off",
// Ban matrix-js-sdk/src imports in favour of matrix-js-sdk/src/matrix imports to prevent unleashing hell.
"no-restricted-imports": [
"error", "error",
{ {
paths: [ allowExpressions: true,
{ },
name: "matrix-js-sdk", ],
message: "Please use matrix-js-sdk/src/matrix instead",
}, // Things we do that break the ideal style
{ "prefer-promise-reject-errors": "off",
name: "matrix-js-sdk/", "no-extra-boolean-cast": "off",
message: "Please use matrix-js-sdk/src/matrix instead",
}, // Remove Babel things manually due to override limitations
{ "@babel/no-invalid-this": ["off"],
name: "matrix-js-sdk/src",
message: "Please use matrix-js-sdk/src/matrix instead", // We're okay being explicit at the moment
}, "@typescript-eslint/no-empty-interface": "off",
{ // We disable this while we're transitioning
name: "matrix-js-sdk/src/", "@typescript-eslint/no-explicit-any": "off",
message: "Please use matrix-js-sdk/src/matrix instead", // 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
name: "matrix-js-sdk/src/index", "@typescript-eslint/no-non-null-assertion": "off",
message: "Please use matrix-js-sdk/src/matrix instead", // We do this sometimes to brand interfaces
}, "@typescript-eslint/no-empty-object-type": "off",
{ },
name: "matrix-react-sdk", },
message: "Please use matrix-react-sdk/src/index instead", // temporary override for offending icon require files
}, {
{ files: [
name: "matrix-react-sdk/", "src/SdkConfig.ts",
message: "Please use matrix-react-sdk/src/index instead", "src/components/structures/FileDropTarget.tsx",
}, "src/components/structures/RoomStatusBar.tsx",
], "src/components/structures/UserMenu.tsx",
patterns: [ "src/components/views/avatars/WidgetAvatar.tsx",
{ "src/components/views/dialogs/AddExistingToSpaceDialog.tsx",
group: ["matrix-js-sdk/lib", "matrix-js-sdk/lib/", "matrix-js-sdk/lib/**"], "src/components/views/dialogs/ForwardDialog.tsx",
message: "Please use matrix-js-sdk/src/* instead", "src/components/views/dialogs/InviteDialog.tsx",
}, "src/components/views/dialogs/ModalWidgetDialog.tsx",
{ "src/components/views/dialogs/UploadConfirmDialog.tsx",
group: ["matrix-react-sdk/lib", "matrix-react-sdk/lib/", "matrix-react-sdk/lib/**"], "src/components/views/dialogs/security/SetupEncryptionDialog.tsx",
message: "Please use matrix-react-sdk/src/* instead", "src/components/views/elements/AddressTile.tsx",
}, "src/components/views/elements/AppWarning.tsx",
], "src/components/views/elements/SSOButtons.tsx",
"src/components/views/messages/MAudioBody.tsx",
"src/components/views/messages/MImageBody.tsx",
"src/components/views/messages/MFileBody.tsx",
"src/components/views/messages/MStickerBody.tsx",
"src/components/views/messages/MVideoBody.tsx",
"src/components/views/messages/MVoiceMessageBody.tsx",
"src/components/views/right_panel/EncryptionPanel.tsx",
"src/components/views/rooms/EntityTile.tsx",
"src/components/views/rooms/LinkPreviewGroup.tsx",
"src/components/views/rooms/MemberList.tsx",
"src/components/views/rooms/MessageComposer.tsx",
"src/components/views/rooms/ReplyPreview.tsx",
"src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx",
"src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx",
],
rules: {
"@typescript-eslint/no-var-requires": "off",
},
},
{
files: ["test/**/*.{ts,tsx}", "playwright/**/*.ts"],
extends: ["plugin:matrix-org/jest"],
rules: {
// We don't need super strict typing in test utilities
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-member-accessibility": "off",
// Jest/Playwright specific
// Disabled tests are a reality for now but as soon as all of the xits are
// eliminated, we should enforce this.
"jest/no-disabled-tests": "off",
// Also treat "oldBackendOnly" as a test function.
// Used in some crypto tests.
"jest/no-standalone-expect": [
"error",
{
additionalTestBlockFunctions: ["beforeAll", "beforeEach", "oldBackendOnly"],
}, },
], ],
}, },
}, },
{ {
files: ["test/**/*.{ts,tsx}"], files: ["playwright/**/*.ts"],
rules: { parserOptions: {
// We don't need super strict typing in test utilities project: ["./playwright/tsconfig.json"],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-member-accessibility": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-floating-promises": "off",
}, },
}, },
], ],
settings: {
react: {
version: "detect",
},
},
}; };
function buildRestrictedPropertiesOptions(properties, message) {
return properties.map((prop) => {
let [object, property] = prop.split(".");
if (object === "*") {
object = undefined;
}
return {
object,
property,
message,
};
});
}

View file

@ -1,2 +1,3 @@
# prettier # prettier
526645c79160ab1ad4b4c3845de27d51263a405e
7921a6cbf86b035d2b0c1daecb4c24beaf5a5abc 7921a6cbf86b035d2b0c1daecb4c24beaf5a5abc

13
.github/CODEOWNERS vendored
View file

@ -2,4 +2,17 @@
/.github/workflows/** @element-hq/element-web-team /.github/workflows/** @element-hq/element-web-team
/package.json @element-hq/element-web-team /package.json @element-hq/element-web-team
/yarn.lock @element-hq/element-web-team /yarn.lock @element-hq/element-web-team
/src/SecurityManager.ts @element-hq/element-crypto-web-reviewers
/test/SecurityManager-test.ts @element-hq/element-crypto-web-reviewers
/src/async-components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/src/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/test/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/src/stores/SetupEncryptionStore.ts @element-hq/element-crypto-web-reviewers
/test/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers
# Ignore translations as those will be updated by GHA for Localazy download
/src/i18n/strings /src/i18n/strings
# Ignore the synapse plugin as this is updated by GHA for docker image updating
/playwright/plugins/homeserver/synapse/index.ts

View file

@ -5,4 +5,4 @@
- [ ] Tests written for new code (and old code if feasible). - [ ] Tests written for new code (and old code if feasible).
- [ ] New or updated `public`/`exported` symbols have accurate [TSDoc](https://tsdoc.org/) documentation. - [ ] New or updated `public`/`exported` symbols have accurate [TSDoc](https://tsdoc.org/) documentation.
- [ ] Linter and other CI checks pass. - [ ] Linter and other CI checks pass.
- [ ] Sign-off given on the changes (see [CONTRIBUTING.md](https://github.com/element-hq/element-web/blob/develop/CONTRIBUTING.md)). - [ ] I have licensed the changes to Element by completing the [Contributor License Agreement (CLA)](https://cla-assistant.io/element-hq/element-web)

View file

@ -1,3 +1,3 @@
_extends: element-hq/matrix-react-sdk _extends: matrix-org/matrix-js-sdk
version-resolver: version-resolver:
default: patch default: patch

View file

@ -10,7 +10,7 @@ on:
jobs: jobs:
backport: backport:
name: Backport name: Backport
runs-on: ubuntu-latest runs-on: ubuntu-24.04
# Only react to merged PRs for security reasons. # Only react to merged PRs for security reasons.
# See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target. # See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target.
if: > if: >

View file

@ -18,15 +18,15 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
image: image:
- ubuntu-latest - ubuntu-24.04
- windows-latest - windows-2022
- macos-latest - macos-14
isDevelop: isDevelop:
- ${{ github.event_name == 'push' && github.ref_name == 'develop' }} - ${{ github.event_name == 'push' && github.ref_name == 'develop' }}
# Skip the ubuntu-latest build for the develop branch as the dedicated CD build_develop workflow handles that # Skip the ubuntu-24.04 build for the develop branch as the dedicated CD build_develop workflow handles that
exclude: exclude:
- isDevelop: true - isDevelop: true
image: ubuntu-latest image: ubuntu-24.04
runs-on: ${{ matrix.image }} runs-on: ${{ matrix.image }}
defaults: defaults:
run: run:

View file

@ -7,7 +7,7 @@ jobs:
build: build:
name: Build package name: Build package
environment: packages.element.io environment: packages.element.io
runs-on: ubuntu-latest runs-on: ubuntu-24.04
env: env:
R2_INCOMING_BUCKET: ${{ vars.R2_INCOMING_BUCKET }} R2_INCOMING_BUCKET: ${{ vars.R2_INCOMING_BUCKET }}
R2_URL: ${{ vars.CF_R2_S3_API }} R2_URL: ${{ vars.CF_R2_S3_API }}

View file

@ -14,7 +14,7 @@ jobs:
name: "Build & Deploy develop.element.io" name: "Build & Deploy develop.element.io"
# Only respect triggers from our develop branch, ignore that of forks # Only respect triggers from our develop branch, ignore that of forks
if: github.repository == 'element-hq/element-web' if: github.repository == 'element-hq/element-web'
runs-on: ubuntu-latest runs-on: ubuntu-24.04
environment: develop environment: develop
env: env:
R2_BUCKET: "element-web-develop" R2_BUCKET: "element-web-develop"

View file

@ -13,17 +13,8 @@ permissions:
jobs: jobs:
buildx: buildx:
name: Docker Buildx name: Docker Buildx
runs-on: ubuntu-latest runs-on: ubuntu-24.04
environment: dockerhub environment: dockerhub
strategy:
fail-fast: false
matrix:
include:
- variant: vanilla
# Variant we ship to aid ESS in providing a build on the OpenCoDE platform including specific modules
- variant: opendesk
flavor: suffix=-opendesk,onlatest=true
prepare: mv variants/openDesk/* .
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@ -32,10 +23,6 @@ jobs:
- name: Install Cosign - name: Install Cosign
uses: sigstore/cosign-installer@4959ce089c160fddf62f7b42464195ba1a56d382 # v3 uses: sigstore/cosign-installer@4959ce089c160fddf62f7b42464195ba1a56d382 # v3
- name: Prepare
if: matrix.prepare
run: ${{ matrix.prepare }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf # v3 uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf # v3
@ -61,7 +48,6 @@ jobs:
type=ref,event=tag type=ref,event=tag
flavor: | flavor: |
latest=${{ contains(github.ref_name, '-rc.') && 'false' || 'auto' }} latest=${{ contains(github.ref_name, '-rc.') && 'false' || 'auto' }}
${{ matrix.flavor }}
- name: Build and push - name: Build and push
id: build-and-push id: build-and-push
@ -85,7 +71,6 @@ jobs:
cosign sign --yes ${images} cosign sign --yes ${images}
- name: Update repo description - name: Update repo description
if: matrix.variant == 'vanilla'
uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4 uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4
continue-on-error: true continue-on-error: true
with: with:

View file

@ -17,7 +17,7 @@ concurrency:
jobs: jobs:
build: build:
name: GitHub Pages name: GitHub Pages
runs-on: ubuntu-latest runs-on: ubuntu-24.04
steps: steps:
- name: Fetch element-desktop - name: Fetch element-desktop
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -30,12 +30,6 @@ jobs:
with: with:
path: element-web path: element-web
- name: Fetch matrix-react-sdk
uses: actions/checkout@v4
with:
repository: element-hq/matrix-react-sdk
path: matrix-react-sdk
- name: Fetch matrix-js-sdk - name: Fetch matrix-js-sdk
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
@ -52,7 +46,7 @@ jobs:
working-directory: element-web working-directory: element-web
run: | run: |
yarn install --frozen-lockfile yarn install --frozen-lockfile
yarn ts-node ./scripts/gen-workflow-mermaid.ts ../element-desktop ../element-web ../matrix-react-sdk ../matrix-js-sdk > docs/automations.md yarn ts-node ./scripts/gen-workflow-mermaid.ts ../element-desktop ../element-web ../matrix-js-sdk > docs/automations.md
echo "- [Automations](automations.md)" >> docs/SUMMARY.md echo "- [Automations](automations.md)" >> docs/SUMMARY.md
- name: Setup mdBook - name: Setup mdBook
@ -74,9 +68,6 @@ jobs:
mv element-web/docs/lib docs/ mv element-web/docs/lib docs/
mv element-web/docs "docs/Element Web" mv element-web/docs "docs/Element Web"
mv matrix-react-sdk/README.md matrix-react-sdk/docs/
mv matrix-react-sdk/docs "docs/Matrix React SDK"
mv matrix-js-sdk/README.md matrix-js-sdk/docs/ mv matrix-js-sdk/README.md matrix-js-sdk/docs/
mv matrix-js-sdk/docs "docs/Matrix JS SDK" mv matrix-js-sdk/docs "docs/Matrix JS SDK"
@ -108,7 +99,7 @@ jobs:
environment: environment:
name: github-pages name: github-pages
url: ${{ steps.deployment.outputs.page_url }} url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest runs-on: ubuntu-24.04
needs: build needs: build
steps: steps:
- name: Deploy to GitHub Pages - name: Deploy to GitHub Pages

View file

@ -0,0 +1,43 @@
# Triggers after the playwright tests have finished,
# taking the artifact and uploading it to Netlify for easier viewing
name: Upload End to End Test report to Netlify
on:
workflow_run:
workflows: ["End to End Tests"]
types:
- completed
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }}
jobs:
report:
if: github.event.workflow_run.conclusion != 'cancelled'
name: Report results
runs-on: ubuntu-22.04
environment: Netlify
permissions:
statuses: write
deployments: write
steps:
- name: Download HTML report
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
name: html-report
path: playwright-report
- name: 📤 Deploy to Netlify
uses: matrix-org/netlify-pr-preview@v3
with:
path: playwright-report
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
branch: ${{ github.event.workflow_run.head_branch }}
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site_id: ${{ vars.NETLIFY_SITE_ID }}
desc: Playwright Report
deployment_env: EndToEndTests
prefix: "e2e-"

View file

@ -1,29 +1,190 @@
# Triggers after the "Downstream artifacts" build has finished, to run the # Produce a build of element-web with this version of react-sdk
# matrix-react-sdk playwright tests (with access to repo secrets) # and any matching branches of element-web and js-sdk, output it
# as an artifact and run end-to-end tests.
name: matrix-react-sdk End to End Tests name: End to End Tests
on: on:
pull_request: {}
merge_group: merge_group:
types: [checks_requested] types: [checks_requested]
pull_request: {}
push: push:
branches: [develop, master] branches: [develop, master]
repository_dispatch:
types: [element-web-notify]
# support triggering from other workflows
workflow_call:
inputs:
skip:
type: boolean
required: false
default: false
description: "A boolean to skip the playwright check itself while still creating the passing check. Useful when only running in Merge Queues."
matrix-js-sdk-sha:
type: string
required: false
description: "The Git SHA of matrix-js-sdk to build against. By default, will use a matching branch name if it exists, or develop."
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }} group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }} cancel-in-progress: true
env:
# fetchdep.sh needs to know our PR number
PR_NUMBER: ${{ github.event.pull_request.number }}
jobs: jobs:
build:
name: "Build Element-Web"
runs-on: ubuntu-22.04
if: inputs.skip != true
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
repository: element-hq/element-web
- uses: actions/setup-node@v4
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
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
- name: Build
env:
CI_PACKAGE: true
VERSION: "${{ steps.layered_build.outputs.VERSION }}"
run: |
yarn build
echo $VERSION > webapp/version
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: webapp
path: webapp
retention-days: 1
playwright: playwright:
name: Playwright name: "Run Tests ${{ matrix.runner }}/${{ strategy.job-total }}"
uses: element-hq/matrix-react-sdk/.github/workflows/end-to-end-tests.yaml@develop needs: build
if: inputs.skip != true
runs-on: ubuntu-22.04
permissions: permissions:
actions: read actions: read
issues: read issues: read
pull-requests: read pull-requests: read
with: strategy:
element-web-sha: ${{ github.sha }} fail-fast: false
react-sdk-repository: element-hq/matrix-react-sdk matrix:
# We only want to run the playwright tests on merge queue to prevent regressions # Run multiple instances in parallel to speed up the tests
# from creeping in. They take a long time to run and consume multiple concurrent runners. runner: [1, 2, 3, 4, 5, 6]
skip: ${{ github.event_name != 'merge_group' }} steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
repository: element-hq/element-web
- name: 📥 Download artifact
uses: actions/download-artifact@v4
with:
name: webapp
path: webapp
- uses: actions/setup-node@v4
with:
cache: "yarn"
cache-dependency-path: yarn.lock
node-version: "lts/*"
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Get installed Playwright version
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@v4
id: playwright-cache
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.playwright.outputs.version }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: yarn playwright install --with-deps
- name: Run Playwright tests
run: yarn playwright test --shard ${{ matrix.runner }}/${{ strategy.job-total }}
- name: Upload blob report to GitHub Actions Artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: all-blob-reports-${{ matrix.runner }}
path: blob-report
retention-days: 1
complete:
name: end-to-end-tests
needs: playwright
if: always()
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
if: inputs.skip != true
with:
persist-credentials: false
repository: element-hq/element-web
- uses: actions/setup-node@v4
if: inputs.skip != true
with:
cache: "yarn"
node-version: "lts/*"
- name: Install dependencies
if: inputs.skip != true
run: yarn install --frozen-lockfile
- name: Download blob reports from GitHub Actions Artifacts
if: inputs.skip != true
uses: actions/download-artifact@v4
with:
pattern: all-blob-reports-*
path: all-blob-reports
merge-multiple: true
- name: Merge into HTML Report
if: inputs.skip != true
run: yarn playwright merge-reports --reporter=html,./playwright/flaky-reporter.ts,./playwright/stale-screenshot-reporter.ts ./all-blob-reports
env:
# Only pass creds to the flaky-reporter on main branch runs
GITHUB_TOKEN: ${{ github.ref_name == 'develop' && secrets.ELEMENT_BOT_TOKEN || '' }}
# 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@v4
with:
name: html-report
path: playwright-report
retention-days: 14
- if: needs.playwright.result != 'skipped' && needs.playwright.result != 'success'
run: exit 1

View file

@ -7,7 +7,7 @@ on:
jobs: jobs:
tidy: tidy:
name: Tidy closed issues name: Tidy closed issues
runs-on: ubuntu-latest runs-on: ubuntu-24.04
steps: steps:
- uses: actions/github-script@v7 - uses: actions/github-script@v7
id: main id: main

48
.github/workflows/netlify.yaml vendored Normal file
View file

@ -0,0 +1,48 @@
# Triggers after the layered build has finished, taking the artifact
# and uploading it to netlify
name: Upload Preview Build to Netlify
on:
workflow_run:
workflows: ["End to End Tests"]
types:
- completed
jobs:
deploy:
if: github.event.workflow_run.conclusion != 'cancelled' && github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-22.04
environment: Netlify
steps:
- name: 📝 Create Deployment
uses: bobheadxi/deployments@648679e8e4915b27893bd7dbc35cb504dc915bc8 # v1
id: deployment
with:
step: start
token: ${{ secrets.GITHUB_TOKEN }}
env: Netlify
ref: ${{ github.event.workflow_run.head_sha }}
desc: |
Do you trust the author of this PR? Maybe this build will steal your keys or give you malware.
Exercise caution. Use test accounts.
- name: 📥 Download artifact
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
name: webapp
path: webapp
- name: 📤 Deploy to Netlify
uses: matrix-org/netlify-pr-preview@v3
with:
path: webapp
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
branch: ${{ github.event.workflow_run.head_branch }}
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site_id: ${{ vars.NETLIFY_SITE_ID }}
deployment_env: ${{ steps.deployment.outputs.env }}
deployment_id: ${{ steps.deployment.outputs.deployment_id }}
desc: |
Do you trust the author of this PR? Maybe this build will steal your keys or give you malware.
Exercise caution. Use test accounts.

View file

@ -9,7 +9,7 @@ concurrency: ${{ github.workflow }}
jobs: jobs:
bot: bot:
name: Pending reviews bot name: Pending reviews bot
runs-on: ubuntu-latest runs-on: ubuntu-24.04
environment: Matrix environment: Matrix
env: env:
URL: "https://github.com/pulls?q=is%3Apr+is%3Aopen+repo%3Amatrix-org%2Fmatrix-js-sdk+repo%3Amatrix-org%2Fmatrix-react-sdk+repo%3Aelement-hq%2Felement-web+repo%3Aelement-hq%2Felement-desktop+review-requested%3A%40me+sort%3Aupdated-desc+" URL: "https://github.com/pulls?q=is%3Apr+is%3Aopen+repo%3Amatrix-org%2Fmatrix-js-sdk+repo%3Amatrix-org%2Fmatrix-react-sdk+repo%3Aelement-hq%2Felement-web+repo%3Aelement-hq%2Felement-desktop+review-requested%3A%40me+sort%3Aupdated-desc+"
@ -64,7 +64,6 @@ jobs:
const repos = [ const repos = [
"element-hq/element-desktop", "element-hq/element-desktop",
"element-hq/element-web", "element-hq/element-web",
"element-hq/matrix-react-sdk",
"matrix-org/matrix-js-sdk", "matrix-org/matrix-js-sdk",
]; ];
const teams = [ const teams = [

View file

@ -0,0 +1,45 @@
name: Update Playwright docker images
on:
workflow_dispatch: {}
schedule:
- cron: "0 6 * * *" # Every day at 6am UTC
jobs:
update:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Update synapse image
run: |
docker pull "$IMAGE"
INSPECT=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE")
DIGEST=${INSPECT#*@}
sed -i "s/const DOCKER_TAG.*/const DOCKER_TAG = \"develop@$DIGEST\";/" playwright/plugins/homeserver/synapse/index.ts
env:
IMAGE: ghcr.io/element-hq/synapse:develop
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@5e914681df9dc83aa4e4905692ca88beb2f9e91f # v7
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
branch: actions/playwright-image-updates
delete-branch: true
title: Playwright Docker image updates
labels: |
T-Task
- name: Enable automerge
run: gh pr merge --merge --auto "$PR_NUMBER"
if: steps.cpr.outputs.pull-request-operation == 'created'
env:
GH_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }}
- name: Enable autoapprove
run: |
gh pr review --approve "$PR_NUMBER"
if: steps.cpr.outputs.pull-request-operation == 'created'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }}

View file

@ -0,0 +1,16 @@
name: Pull Request Base Branch
on:
pull_request:
types: [opened, edited, synchronize]
jobs:
check_base_branch:
name: Check PR base branch
runs-on: ubuntu-22.04
steps:
- uses: actions/github-script@v7
with:
script: |
const baseBranch = context.payload.pull_request.base.ref;
if (!['develop', 'staging'].includes(baseBranch) && !baseBranch.startsWith('feat/')) {
core.setFailed(`Invalid base branch: ${baseBranch}`);
}

View file

@ -7,5 +7,3 @@ concurrency: ${{ github.workflow }}
jobs: jobs:
draft: draft:
uses: matrix-org/matrix-js-sdk/.github/workflows/release-drafter-workflow.yml@develop uses: matrix-org/matrix-js-sdk/.github/workflows/release-drafter-workflow.yml@develop
with:
include-changes: matrix-react-sdk

View file

@ -11,5 +11,4 @@ jobs:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
with: with:
dependencies: | dependencies: |
matrix-react-sdk
matrix-js-sdk matrix-js-sdk

View file

@ -27,7 +27,7 @@ jobs:
notify-downstream: notify-downstream:
name: Trigger release drafter downstream name: Trigger release drafter downstream
needs: release needs: release
runs-on: ubuntu-latest runs-on: ubuntu-24.04
steps: steps:
- name: Notify element-desktop repo that element-web release has completed to re-trigger release-drafter - name: Notify element-desktop repo that element-web release has completed to re-trigger release-drafter
uses: benc-uk/workflow-dispatch@e2e5e9a103e331dad343f381a29e654aea3cf8fc # v1 uses: benc-uk/workflow-dispatch@e2e5e9a103e331dad343f381a29e654aea3cf8fc # v1
@ -41,7 +41,7 @@ jobs:
check: check:
name: Post release checks name: Post release checks
needs: release needs: release
runs-on: ubuntu-latest runs-on: ubuntu-24.04
steps: steps:
- name: Wait for dockerhub - name: Wait for dockerhub
uses: t3chguy/wait-on-check-action@18541021811b56544d90e0f073401c2b99e249d6 # fork uses: t3chguy/wait-on-check-action@18541021811b56544d90e0f073401c2b99e249d6 # fork

View file

@ -12,11 +12,6 @@ on:
required: true required: true
type: boolean type: boolean
default: true default: true
matrix-react-sdk:
description: Prepare matrix-react-sdk
required: true
type: boolean
default: true
matrix-js-sdk: matrix-js-sdk:
description: Prepare matrix-js-sdk description: Prepare matrix-js-sdk
required: true required: true
@ -24,10 +19,7 @@ on:
default: true default: true
jobs: jobs:
prepare: prepare:
runs-on: ubuntu-latest runs-on: ubuntu-24.04
env:
# The order is specified bottom-up to avoid any races for allchange
REPOS: matrix-js-sdk matrix-react-sdk element-web element-desktop
steps: steps:
- name: Checkout Element Desktop - name: Checkout Element Desktop
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -49,16 +41,6 @@ jobs:
fetch-depth: 0 fetch-depth: 0
fetch-tags: true fetch-tags: true
token: ${{ secrets.ELEMENT_BOT_TOKEN }} token: ${{ secrets.ELEMENT_BOT_TOKEN }}
- name: Checkout Matrix React SDK
uses: actions/checkout@v4
if: inputs.matrix-react-sdk
with:
repository: element-hq/matrix-react-sdk
path: matrix-react-sdk
ref: staging
fetch-depth: 0
fetch-tags: true
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
- name: Checkout Matrix JS SDK - name: Checkout Matrix JS SDK
uses: actions/checkout@v4 uses: actions/checkout@v4
if: inputs.matrix-js-sdk if: inputs.matrix-js-sdk
@ -83,10 +65,6 @@ jobs:
if: inputs.element-web if: inputs.element-web
run: | run: |
git -C "element-web" merge origin/develop git -C "element-web" merge origin/develop
- name: Merge React SDK
if: inputs.matrix-react-sdk
run: |
git -C "matrix-react-sdk" merge origin/develop
- name: Merge JS SDK - name: Merge JS SDK
if: inputs.matrix-js-sdk if: inputs.matrix-js-sdk
run: | run: |
@ -106,17 +84,6 @@ jobs:
check-name: draft check-name: draft
allowed-conclusions: success allowed-conclusions: success
- name: Wait for matrix-react-sdk draft
if: inputs.matrix-react-sdk
uses: t3chguy/wait-on-check-action@18541021811b56544d90e0f073401c2b99e249d6 # fork
with:
ref: staging
repo: element-hq/matrix-react-sdk
repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
wait-interval: 10
check-name: draft
allowed-conclusions: success
- name: Wait for element-web draft - name: Wait for element-web draft
if: inputs.element-web if: inputs.element-web
uses: t3chguy/wait-on-check-action@18541021811b56544d90e0f073401c2b99e249d6 # fork uses: t3chguy/wait-on-check-action@18541021811b56544d90e0f073401c2b99e249d6 # fork

View file

@ -10,7 +10,10 @@ concurrency:
jobs: jobs:
sonarqube: sonarqube:
name: 🩻 SonarQube name: 🩻 SonarQube
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group'
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
secrets: secrets:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
with:
sharded: true

View file

@ -7,14 +7,19 @@ on:
types: [checks_requested] types: [checks_requested]
repository_dispatch: repository_dispatch:
types: [element-web-notify] types: [element-web-notify]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true
env: env:
# These 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 }} REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }} PR_NUMBER: ${{ github.event.pull_request.number }}
jobs: jobs:
ts_lint: ts_lint:
name: "Typescript Syntax Check" name: "Typescript Syntax Check"
runs-on: ubuntu-latest runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -29,15 +34,54 @@ jobs:
- name: Typecheck - name: Typecheck
run: "yarn run lint:types" run: "yarn run lint:types"
- name: Switch js-sdk to release mode
working-directory: node_modules/matrix-js-sdk
run: |
scripts/switch_package_to_release.cjs
yarn install
yarn run build:compile
yarn run build:types
- name: Typecheck (release mode)
run: "yarn run lint:types"
# Temporary while we directly import matrix-js-sdk/src/* which means we need
# certain @types/* packages to make sense of matrix-js-sdk types.
#- name: Typecheck (release mode; no yarn link)
# if: github.event_name != 'pull_request' && github.ref_name != 'master'
# run: |
# yarn unlink matrix-js-sdk
# yarn add github:matrix-org/matrix-js-sdk#develop
# yarn install --force
# yarn run lint:types
i18n_lint: i18n_lint:
name: "i18n Check" name: "i18n Check"
uses: matrix-org/matrix-web-i18n/.github/workflows/i18n_check.yml@main uses: matrix-org/matrix-web-i18n/.github/workflows/i18n_check.yml@main
with: with:
hardcoded-words: "Element" hardcoded-words: "Element"
allowed-hardcoded-keys: |
console_dev_note
labs|element_call_video_rooms
labs|feature_disable_call_per_sender_encryption
voip|element_call
error|invalid_json
error|misconfigured
welcome_to_element
rethemendex_lint:
name: "Rethemendex Check"
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- run: ./res/css/rethemendex.sh
- run: git diff --exit-code
js_lint: js_lint:
name: "ESLint" name: "ESLint"
runs-on: ubuntu-latest runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -55,7 +99,7 @@ jobs:
style_lint: style_lint:
name: "Style Lint" name: "Style Lint"
runs-on: ubuntu-latest runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -64,16 +108,16 @@ jobs:
cache: "yarn" cache: "yarn"
node-version: "lts/*" node-version: "lts/*"
# Needs branch matching as it inherits .stylelintrc.js from matrix-react-sdk # Does not need branch matching as only analyses this layer
- name: Install Dependencies - name: Install Deps
run: "./scripts/layered.sh" run: "yarn install"
- name: Run Linter - name: Run Linter
run: "yarn run lint:style" run: "yarn run lint:style"
workflow_lint: workflow_lint:
name: "Workflow Lint" name: "Workflow Lint"
runs-on: ubuntu-latest runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -91,7 +135,7 @@ jobs:
analyse_dead_code: analyse_dead_code:
name: "Analyse Dead Code" name: "Analyse Dead Code"
runs-on: ubuntu-latest runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View file

@ -1,60 +0,0 @@
name: Tests
on:
pull_request: {}
push:
branches: [develop, master]
merge_group:
types: [checks_requested]
repository_dispatch:
types: [element-web-notify]
env:
# These must be set for fetchdep.sh to get the right branch
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
jobs:
jest:
name: Jest
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Yarn cache
uses: actions/setup-node@v4
with:
cache: "yarn"
node-version: "lts/*"
- name: Install Dependencies
run: "./scripts/layered.sh"
- name: Get number of CPU cores
id: cpu-cores
uses: SimenB/github-actions-cpu-cores@97ba232459a8e02ff6121db9362b09661c875ab8 # v2
- name: Run tests with coverage
run: "yarn coverage --ci --max-workers ${{ steps.cpu-cores.outputs.count }}"
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: coverage
path: |
coverage
!coverage/lcov-report
skip_sonar:
name: Skip SonarCloud in merge queue
if: github.event_name == 'merge_group'
runs-on: ubuntu-latest
needs: jest
steps:
- name: Skip SonarCloud
uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
description: SonarCloud skipped
context: SonarCloud Code Analysis
sha: ${{ github.sha }}
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}

110
.github/workflows/tests.yml vendored Normal file
View file

@ -0,0 +1,110 @@
name: Tests
on:
pull_request: {}
merge_group:
types: [checks_requested]
push:
branches: [develop, master]
repository_dispatch:
types: [element-web-notify]
workflow_call:
inputs:
disable_coverage:
type: boolean
required: false
description: "Specify true to skip generating and uploading coverage for tests"
matrix-js-sdk-sha:
type: string
required: false
description: "The matrix-js-sdk SHA to use"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true
env:
ENABLE_COVERAGE: ${{ github.event_name != 'merge_group' && inputs.disable_coverage != 'true' }}
# fetchdep.sh needs to know our PR number
PR_NUMBER: ${{ github.event.pull_request.number }}
jobs:
jest:
name: Jest
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
# Run multiple instances in parallel to speed up the tests
runner: [1, 2]
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-web' || github.repository }}
- name: Yarn cache
uses: actions/setup-node@v4
with:
node-version: "lts/*"
cache: "yarn"
- name: Install Deps
run: "./scripts/layered.sh"
env:
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
- name: Jest Cache
uses: actions/cache@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
run: |
yarn test \
--coverage=${{ env.ENABLE_COVERAGE }} \
--ci \
--max-workers ${{ steps.cpu-cores.outputs.count }} \
--shard ${{ matrix.runner }}/${{ strategy.job-total }} \
--cacheDirectory /tmp/jest_cache
env:
JEST_SONAR_UNIQUE_OUTPUT_NAME: true
# tell jest to use coloured output
FORCE_COLOR: true
- name: Move coverage files into place
if: env.ENABLE_COVERAGE == 'true'
run: mv coverage/lcov.info coverage/${{ steps.setupNode.outputs.node-version }}-${{ matrix.runner }}.lcov.info
- name: Upload Artifact
if: env.ENABLE_COVERAGE == 'true'
uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.runner }}
path: |
coverage
!coverage/lcov-report
complete:
name: jest-tests
needs: jest
if: always()
runs-on: ubuntu-22.04
steps:
- 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: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
description: SonarCloud skipped
context: SonarCloud Code Analysis
sha: ${{ github.sha }}
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}

View file

@ -6,7 +6,7 @@ on:
jobs: jobs:
web-app-team: web-app-team:
runs-on: ubuntu-latest runs-on: ubuntu-24.04
if: | if: |
contains(github.event.issue.assignees.*.login, 't3chguy') || contains(github.event.issue.assignees.*.login, 't3chguy') ||
contains(github.event.issue.assignees.*.login, 'andybalaam') || contains(github.event.issue.assignees.*.login, 'andybalaam') ||

View file

@ -6,7 +6,7 @@ on:
jobs: jobs:
automate-project-columns: automate-project-columns:
runs-on: ubuntu-latest runs-on: ubuntu-24.04
steps: steps:
- uses: actions/add-to-project@main - uses: actions/add-to-project@main
with: with:

View file

@ -11,7 +11,7 @@ on:
jobs: jobs:
apply_Z-Labs_label: apply_Z-Labs_label:
name: Add Z-Labs label for features behind labs flags name: Add Z-Labs label for features behind labs flags
runs-on: ubuntu-latest runs-on: ubuntu-24.04
if: > if: >
contains(github.event.issue.labels.*.name, 'A-Maths') || contains(github.event.issue.labels.*.name, 'A-Maths') ||
contains(github.event.issue.labels.*.name, 'A-Location-Sharing') || contains(github.event.issue.labels.*.name, 'A-Location-Sharing') ||
@ -37,7 +37,7 @@ jobs:
apply_Help-Wanted_label: apply_Help-Wanted_label:
name: Add "Help Wanted" label to all "good first issue" and Hacktoberfest name: Add "Help Wanted" label to all "good first issue" and Hacktoberfest
runs-on: ubuntu-latest runs-on: ubuntu-24.04
if: > if: >
contains(github.event.issue.labels.*.name, 'good first issue') || contains(github.event.issue.labels.*.name, 'good first issue') ||
contains(github.event.issue.labels.*.name, 'Hacktoberfest') contains(github.event.issue.labels.*.name, 'Hacktoberfest')
@ -54,7 +54,7 @@ jobs:
move_needs_info_issues: move_needs_info_issues:
name: X-Needs-Info issues to Need info column on triage board name: X-Needs-Info issues to Need info column on triage board
runs-on: ubuntu-latest runs-on: ubuntu-24.04
if: > if: >
contains(github.event.issue.labels.*.name, 'X-Needs-Info') contains(github.event.issue.labels.*.name, 'X-Needs-Info')
steps: steps:
@ -77,7 +77,7 @@ jobs:
move_flakey_test_issues: move_flakey_test_issues:
name: Z-Flaky-Test issues to Sized for maintainer column on triage board name: Z-Flaky-Test issues to Sized for maintainer column on triage board
runs-on: ubuntu-latest runs-on: ubuntu-24.04
if: > if: >
contains(github.event.issue.labels.*.name, 'Z-Flaky-Test') contains(github.event.issue.labels.*.name, 'Z-Flaky-Test')
steps: steps:
@ -100,7 +100,7 @@ jobs:
add_priority_design_issues_to_project: add_priority_design_issues_to_project:
name: P1 X-Needs-Design to Design project board name: P1 X-Needs-Design to Design project board
runs-on: ubuntu-latest runs-on: ubuntu-24.04
if: > if: >
contains(github.event.issue.labels.*.name, 'X-Needs-Design') && contains(github.event.issue.labels.*.name, 'X-Needs-Design') &&
(contains(github.event.issue.labels.*.name, 'S-Critical') && (contains(github.event.issue.labels.*.name, 'S-Critical') &&
@ -117,7 +117,7 @@ jobs:
add_product_issues: add_product_issues:
name: X-Needs-Product to product project board name: X-Needs-Product to product project board
runs-on: ubuntu-latest runs-on: ubuntu-24.04
if: > if: >
contains(github.event.issue.labels.*.name, 'X-Needs-Product') contains(github.event.issue.labels.*.name, 'X-Needs-Product')
steps: steps:
@ -128,7 +128,7 @@ jobs:
Search_issues_to_board: Search_issues_to_board:
name: Search issues to project board name: Search issues to project board
runs-on: ubuntu-latest runs-on: ubuntu-24.04
if: > if: >
contains(github.event.issue.labels.*.name, 'A-New-Search-Experience') contains(github.event.issue.labels.*.name, 'A-New-Search-Experience')
steps: steps:
@ -139,7 +139,7 @@ jobs:
voip: voip:
name: Add labelled issues to VoIP project board name: Add labelled issues to VoIP project board
runs-on: ubuntu-latest runs-on: ubuntu-24.04
if: > if: >
contains(github.event.issue.labels.*.name, 'Team: VoIP') contains(github.event.issue.labels.*.name, 'Team: VoIP')
steps: steps:
@ -150,7 +150,7 @@ jobs:
verticals_feature: verticals_feature:
name: Add labelled issues to Verticals Feature project name: Add labelled issues to Verticals Feature project
runs-on: ubuntu-latest runs-on: ubuntu-24.04
if: > if: >
contains(github.event.issue.labels.*.name, 'Team: Verticals Feature') contains(github.event.issue.labels.*.name, 'Team: Verticals Feature')
steps: steps:
@ -161,7 +161,7 @@ jobs:
tech_debt: tech_debt:
name: Add labelled issues to tech debt project name: Add labelled issues to tech debt project
runs-on: ubuntu-latest runs-on: ubuntu-24.04
if: > if: >
contains(github.event.issue.labels.*.name, 'A-Developer-Experience') || contains(github.event.issue.labels.*.name, 'A-Developer-Experience') ||
contains(github.event.issue.labels.*.name, 'A-Documentation') || contains(github.event.issue.labels.*.name, 'A-Documentation') ||

View file

@ -6,7 +6,7 @@ on:
jobs: jobs:
add_design_pr_to_project: add_design_pr_to_project:
name: Move PRs asking for design review to the design board name: Move PRs asking for design review to the design board
runs-on: ubuntu-latest runs-on: ubuntu-24.04
steps: steps:
- uses: octokit/graphql-action@v2.x - uses: octokit/graphql-action@v2.x
id: find_team_members id: find_team_members
@ -73,7 +73,7 @@ jobs:
add_product_pr_to_project: add_product_pr_to_project:
name: Move PRs asking for design review to the design board name: Move PRs asking for design review to the design board
runs-on: ubuntu-latest runs-on: ubuntu-24.04
steps: steps:
- uses: octokit/graphql-action@v2.x - uses: octokit/graphql-action@v2.x
id: find_team_members id: find_team_members

View file

@ -4,7 +4,7 @@ on:
- cron: "30 1 * * *" - cron: "30 1 * * *"
jobs: jobs:
close: close:
runs-on: ubuntu-latest runs-on: ubuntu-24.04
permissions: permissions:
actions: write actions: write
issues: write issues: write

View file

@ -7,7 +7,7 @@ on:
jobs: jobs:
Move_Unabeled_Issue_On_Project_Board: Move_Unabeled_Issue_On_Project_Board:
name: Move no longer X-Needs-Info issues to Triaged name: Move no longer X-Needs-Info issues to Triaged
runs-on: ubuntu-latest runs-on: ubuntu-24.04
if: > if: >
${{ ${{
!contains(github.event.issue.labels.*.name, 'X-Needs-Info') }} !contains(github.event.issue.labels.*.name, 'X-Needs-Info') }}
@ -44,7 +44,7 @@ jobs:
remove_Z-Labs_label: remove_Z-Labs_label:
name: Remove Z-Labs label when features behind labs flags are removed name: Remove Z-Labs label when features behind labs flags are removed
runs-on: ubuntu-latest runs-on: ubuntu-24.04
if: > if: >
!(contains(github.event.issue.labels.*.name, 'A-Maths') || !(contains(github.event.issue.labels.*.name, 'A-Maths') ||
contains(github.event.issue.labels.*.name, 'A-Message-Pinning') || contains(github.event.issue.labels.*.name, 'A-Message-Pinning') ||

View file

@ -6,7 +6,7 @@ on:
- cron: "0 3 * * 0" # 3am every Sunday - cron: "0 3 * * 0" # 3am every Sunday
jobs: jobs:
update: update:
runs-on: ubuntu-latest runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View file

@ -18,7 +18,7 @@ concurrency: ${{ github.workflow }}
jobs: jobs:
bot: bot:
name: Release topic update name: Release topic update
runs-on: ubuntu-latest runs-on: ubuntu-24.04
environment: Matrix environment: Matrix
steps: steps:
- uses: actions/github-script@v7 - uses: actions/github-script@v7
@ -56,7 +56,7 @@ jobs:
headers, headers,
body: "{}", body: "{}",
}); });
let res = await fetch(apiUrl, { let res = await fetch(apiUrl, {
method: "GET", method: "GET",
headers, headers,
@ -69,7 +69,7 @@ jobs:
const data = await res.json(); const data = await res.json();
console.log(roomId, "got event", data); console.log(roomId, "got event", data);
const topic = data.topic.replace(regex, releaseTopic); const topic = data.topic.replace(regex, releaseTopic);
if (topic === data.topic) { if (topic === data.topic) {
console.log(roomId, "nothing to do"); console.log(roomId, "nothing to do");
@ -89,7 +89,7 @@ jobs:
}), }),
headers, headers,
}); });
if (res.ok) { if (res.ok) {
console.log(roomId, "topic updated:", topic); console.log(roomId, "topic updated:", topic);
} else { } else {

2
.gitignore vendored
View file

@ -29,3 +29,5 @@ electron/pub
/build_config.yaml /build_config.yaml
/book /book
/index.html /index.html
# version file and tarball created by `npm pack` / `yarn pack`
/git-revision.txt

1
.node-version Normal file
View file

@ -0,0 +1 @@
20

View file

@ -25,10 +25,22 @@ src/vector/modernizr.js
/docs/lib /docs/lib
/book /book
/debian/tmp /debian/tmp
/.npmrc
package-lock.json
# This file is owned, parsed, and generated by allchange, which doesn't comply with prettier # This file is owned, parsed, and generated by allchange, which doesn't comply with prettier
/CHANGELOG.md /CHANGELOG.md
/docs/changelogs /docs/changelogs
# Legacy skinning file that some people might still have
/src/component-index.js
# Downloaded and already minified # Downloaded and already minified
res/jitsi_external_api.min.js res/jitsi_external_api.min.js
# This file is also machine-generated
/playwright/e2e/crypto/test_indexeddb_cryptostore_dump/dump.json
/playwright/test-results/
/playwright/html-report/
/playwright/logs/
/playwright/snapshots/

1
.prettierrc.cjs Normal file
View file

@ -0,0 +1 @@
module.exports = require("eslint-plugin-matrix-org/.prettierrc.js");

View file

@ -1,4 +1,50 @@
module.exports = { module.exports = {
...require("matrix-react-sdk/.stylelintrc.js"),
extends: ["stylelint-config-standard"], extends: ["stylelint-config-standard"],
customSyntax: require("postcss-scss"),
plugins: ["stylelint-scss"],
rules: {
"comment-empty-line-before": null,
"declaration-empty-line-before": null,
"length-zero-no-unit": null,
"rule-empty-line-before": null,
"color-hex-length": null,
"at-rule-no-unknown": null,
"no-descending-specificity": null,
"scss/at-rule-no-unknown": [
true,
{
// https://github.com/vector-im/element-web/issues/10544
ignoreAtRules: ["define-mixin"],
},
],
// Disable `&_kind`-style selectors while our unused CSS approach is "Find & Replace All"
// rather than a CI thing. Shorthand selectors are harder to detect when searching for a
// class name. This regex is trying to *allow* anything except `&words`, such as `&::before`,
// `&.mx_Class`, etc.
"selector-nested-pattern": "^((&[ :.\\[,])|([^&]))",
// Disable some defaults
"selector-class-pattern": null,
"custom-property-pattern": null,
"selector-id-pattern": null,
"keyframes-name-pattern": null,
"alpha-value-notation": null,
"color-function-notation": null,
"selector-not-notation": null,
"import-notation": null,
"value-keyword-case": null,
"declaration-block-no-redundant-longhand-properties": null,
"declaration-block-no-duplicate-properties": [
true,
// useful for fallbacks
{ ignore: ["consecutive-duplicates-with-different-values"] },
],
"shorthand-property-no-redundant-values": null,
"property-no-vendor-prefix": null,
"value-no-vendor-prefix": null,
"selector-no-vendor-prefix": null,
"media-feature-name-no-vendor-prefix": null,
"number-max-precision": null,
"no-invalid-double-slash-comments": true,
"media-feature-range-notation": null,
},
}; };

View file

@ -79,7 +79,6 @@ element-web notes: Fix a bug where the 'Herd' button only worked on Tuesdays
This example is for Element Web. You can specify: This example is for Element Web. You can specify:
- matrix-react-sdk
- element-web - element-web
- element-desktop - element-desktop
@ -113,14 +112,12 @@ checks, so please check back after a few minutes.
Your PR should include tests. Your PR should include tests.
For new user facing features in `matrix-js-sdk`, `matrix-react-sdk` or `element-web`, you For new user facing features in `matrix-js-sdk` or `element-web`, you must include:
must include:
1. Comprehensive unit tests written in Jest. These are located in `/test`. 1. Comprehensive unit tests written in Jest. These are located in `/test`.
2. "happy path" end-to-end tests. 2. "happy path" end-to-end tests.
These are located in `/playwright/e2e` in `matrix-react-sdk`, and These are located in `/playwright/e2e`, and are run using `element-web`.
are run using `element-web`. Ideally, you would also include tests for edge Ideally, you would also include tests for edge and error cases.
and error cases.
Unit tests are expected even when the feature is in labs. It's good practice Unit tests are expected even when the feature is in labs. It's good practice
to write tests alongside the code as it ensures the code is testable from to write tests alongside the code as it ensures the code is testable from
@ -134,8 +131,7 @@ end-to-end test; which is best depends on what sort of test most concisely
exercises the area. exercises the area.
Changes to must be accompanied by unit tests written in Jest. Changes to must be accompanied by unit tests written in Jest.
These are located in `/spec/` in `matrix-js-sdk` or `/test/` in `element-web` These are located in `/spec/` in `matrix-js-sdk` or `/test/` in `element-web`.
and `matrix-react-sdk`.
When writing unit tests, please aim for a high level of test coverage When writing unit tests, please aim for a high level of test coverage
for new code - 80% or greater. If you cannot achieve that, please document for new code - 80% or greater. If you cannot achieve that, please document

View file

@ -1,11 +1,8 @@
# Builder # Builder
FROM --platform=$BUILDPLATFORM node:20-bullseye as builder FROM --platform=$BUILDPLATFORM node:20-bullseye as builder
# Support custom branches of the react-sdk and js-sdk. This also helps us build # Support custom branch of the js-sdk. This also helps us build images of element-web develop.
# images of element-web develop.
ARG USE_CUSTOM_SDKS=false ARG USE_CUSTOM_SDKS=false
ARG REACT_SDK_REPO="https://github.com/matrix-org/matrix-react-sdk.git"
ARG REACT_SDK_BRANCH="master"
ARG JS_SDK_REPO="https://github.com/matrix-org/matrix-js-sdk.git" ARG JS_SDK_REPO="https://github.com/matrix-org/matrix-js-sdk.git"
ARG JS_SDK_BRANCH="master" ARG JS_SDK_BRANCH="master"

View file

@ -10,7 +10,7 @@
# Element # Element
Element (formerly known as Vector and Riot) is a Matrix web client built using the [Matrix Element (formerly known as Vector and Riot) is a Matrix web client built using the [Matrix
React SDK](https://github.com/matrix-org/matrix-react-sdk). JS SDK](https://github.com/matrix-org/matrix-js-sdk).
# Supported Environments # Supported Environments
@ -208,10 +208,9 @@ into Element itself.
# Setting up a dev environment # Setting up a dev environment
Much of the functionality in Element is actually in the `matrix-react-sdk` and Much of the functionality in Element is actually in the `matrix-js-sdk` module.
`matrix-js-sdk` modules. It is possible to set these up in a way that makes it It is possible to set these up in a way that makes it easy to track the `develop` branches
easy to track the `develop` branches in git and to make local changes without in git and to make local changes without having to manually rebuild each time.
having to manually rebuild each time.
First clone and build `matrix-js-sdk`: First clone and build `matrix-js-sdk`:
@ -223,17 +222,6 @@ yarn install
popd popd
``` ```
Then similarly with `matrix-react-sdk`:
```bash
git clone https://github.com/matrix-org/matrix-react-sdk.git
pushd matrix-react-sdk
yarn link
yarn link matrix-js-sdk
yarn install
popd
```
Clone the repo and switch to the `element-web` directory: Clone the repo and switch to the `element-web` directory:
```bash ```bash
@ -248,7 +236,6 @@ Finally, build and start Element itself:
```bash ```bash
yarn link matrix-js-sdk yarn link matrix-js-sdk
yarn link matrix-react-sdk
yarn install yarn install
yarn start yarn start
``` ```
@ -294,8 +281,7 @@ sudo sysctl -p
--- ---
When you make changes to `matrix-react-sdk` or `matrix-js-sdk` they should be When you make changes to `matrix-js-sdk` they should be automatically picked up by webpack and built.
automatically picked up by webpack and built.
If any of these steps error with, `file table overflow`, you are probably on a mac If any of these steps error with, `file table overflow`, you are probably on a mac
which has a very low limit on max open files. Run `ulimit -Sn 1024` and try again. which has a very low limit on max open files. Run `ulimit -Sn 1024` and try again.

6
__mocks__/FontManager.js Normal file
View file

@ -0,0 +1,6 @@
// Stub out FontManager for tests as it doesn't validate anything we don't already know given
// our fixed test environment and it requires the installation of node-canvas.
module.exports = {
fixupColorFonts: () => Promise.resolve(),
};

2
__mocks__/empty.js Normal file
View file

@ -0,0 +1,2 @@
// Yes, this is empty.
module.exports = {};

1
__mocks__/imageMock.js Normal file
View file

@ -0,0 +1 @@
module.exports = "image-file-stub";

4
__mocks__/languages.json Normal file
View file

@ -0,0 +1,4 @@
{
"en": "en_EN.json",
"en-us": "en_US.json"
}

40
__mocks__/maplibre-gl.js Normal file
View file

@ -0,0 +1,40 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
const EventEmitter = require("events");
const { LngLat, NavigationControl, LngLatBounds } = require("maplibre-gl");
class MockMap extends EventEmitter {
addControl = jest.fn();
removeControl = jest.fn();
zoomIn = jest.fn();
zoomOut = jest.fn();
setCenter = jest.fn();
setStyle = jest.fn();
fitBounds = jest.fn();
}
const MockMapInstance = new MockMap();
class MockAttributionControl {}
class MockGeolocateControl extends EventEmitter {
trigger = jest.fn();
}
const MockGeolocateInstance = new MockGeolocateControl();
const MockMarker = {};
MockMarker.setLngLat = jest.fn().mockReturnValue(MockMarker);
MockMarker.addTo = jest.fn().mockReturnValue(MockMarker);
MockMarker.remove = jest.fn().mockReturnValue(MockMarker);
module.exports = {
Map: jest.fn().mockReturnValue(MockMapInstance),
GeolocateControl: jest.fn().mockReturnValue(MockGeolocateInstance),
Marker: jest.fn().mockReturnValue(MockMarker),
LngLat,
LngLatBounds,
NavigationControl,
AttributionControl: MockAttributionControl,
};

2
__mocks__/svg.js Normal file
View file

@ -0,0 +1,2 @@
export const Icon = "div";
export default "image-file-stub";

View file

@ -0,0 +1,11 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
export default function workerFactory(options) {
return jest.fn;
}

View file

@ -5,7 +5,6 @@ adjacent to. As of writing, these are:
- element-desktop - element-desktop
- element-web - element-web
- matrix-react-sdk
- matrix-js-sdk - matrix-js-sdk
Other projects might extend this code style for increased strictness. For example, matrix-events-sdk Other projects might extend this code style for increased strictness. For example, matrix-events-sdk

View file

@ -34,8 +34,22 @@
- [App load order](app-load.md) - [App load order](app-load.md)
- [Translation](translating-dev.md) - [Translation](translating-dev.md)
- [Theming](theming.md) - [Theming](theming.md)
- [Playwright end to end tests](playwright.md)
- [Memory profiling](memory-profiles-and-leaks.md) - [Memory profiling](memory-profiles-and-leaks.md)
- [Jitsi](jitsi-dev.md) - [Jitsi](jitsi-dev.md)
- [Feature flags](feature-flags.md) - [Feature flags](feature-flags.md)
- [OIDC and delegated authentication](oidc.md) - [OIDC and delegated authentication](oidc.md)
- [Release Process](release.md) - [Release Process](release.md)
# Deep dive
- [Skinning](skinning.md)
- [Cider editor](ciderEditor.md)
- [Iconography](icons.md)
- [Jitsi](jitsi.md)
- [Local echo](local-echo-dev.md)
- [Media](media-handling.md)
- [Room List Store](room-list-store.md)
- [Scrolling](scrolling.md)
- [Usercontent](usercontent.md)
- [Widget layouts](widget-layouts.md)

View file

@ -19,8 +19,7 @@ If you're looking for inspiration on where to start, keep reading!
All the issues for Element Web live in the All the issues for Element Web live in the
[element-web](https://github.com/element-hq/element-web) repository, including [element-web](https://github.com/element-hq/element-web) repository, including
issues that actually need fixing in `matrix-react-sdk` or one of the related issues that actually need fixing in one of the related repos.
repos.
The first place to look is for The first place to look is for
[issues tagged with "good first issue"](https://github.com/element-hq/element-web/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22). [issues tagged with "good first issue"](https://github.com/element-hq/element-web/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22).

71
docs/ciderEditor.md Normal file
View file

@ -0,0 +1,71 @@
# The CIDER (Contenteditable-Input-Diff-Error-Reconcile) editor
The CIDER editor is a custom editor written for Element.
Most of the code can be found in the `/editor/` directory.
It is used to power the composer main composer (both to send and edit messages), and might be used for other usecases where autocomplete is desired (invite box, ...).
## High-level overview.
The editor is backed by a model that contains parts.
A part has some text and a type (plain text, pill, ...). When typing in the editor,
the model validates the input and updates the parts.
The parts are then reconciled with the DOM.
## Inner workings
When typing in the `contenteditable` element, the `input` event fires and
the DOM of the editor is turned into a string. The way this is done has
some logic to it to deal with adding newlines for block elements, to make sure
the caret offset is calculated in the same way as the content string, and to ignore
caret nodes (more on that later).
For these reasons it doesn't use `innerText`, `textContent` or anything similar.
The model addresses any content in the editor within as an offset within this string.
The caret position is thus also converted from a position in the DOM tree
to an offset in the content string. This happens in `getCaretOffsetAndText` in `dom.ts`.
Once the content string and caret offset is calculated, it is passed to the `update()`
method of the model. The model first calculates the same content string of its current parts,
basically just concatenating their text. It then looks for differences between
the current and the new content string. The diffing algorithm is very basic,
and assumes there is only one change around the caret offset,
so this should be very inexpensive. See `diff.ts` for details.
The result of the diffing is the strings that were added and/or removed from
the current content. These differences are then applied to the parts,
where parts can apply validation logic to these changes.
For example, if you type an @ in some plain text, the plain text part rejects
that character, and this character is then presented to the part creator,
which will turn it into a pill candidate part.
Pill candidate parts are what opens the auto completion, and upon picking a completion,
replace themselves with an actual pill which can't be edited anymore.
The diffing is needed to preserve state in the parts apart from their text
(which is the only thing the model receives from the DOM), e.g. to build
the model incrementally. Any text that didn't change is assumed
to leave the parts it intersects alone.
The benefit of this is that we can use the `input` event, which is broadly supported,
to find changes in the editor. We don't have to rely on keyboard events,
which relate poorly to text input or changes, and don't need the `beforeinput` event,
which isn't broadly supported yet.
Once the parts of the model are updated, the DOM of the editor is then reconciled
with the new model state, see `renderModel` in `render.ts` for this.
If the model didn't reject the input and didn't make any additional changes,
this won't make any changes to the DOM at all, and should thus be fairly efficient.
For the browser to allow the user to place the caret between two pills,
or between a pill and the start and end of the line, we need some extra DOM nodes.
These DOM nodes are called caret nodes, and contain an invisble character, so
the caret can be placed into them. The model is unaware of caret nodes, and they
are only added to the DOM during the render phase. Likewise, when calculating
the content string, caret nodes need to be ignored, as they would confuse the model.
As part of the reconciliation, the caret position is also adjusted to any changes
the model made to the input. The caret is passed around in two formats.
The model receives the caret _offset_ within the content string (which includes
an atNodeEnd flag to make it unambiguous if it is at a part and or the next part start).
The model converts this to a caret _position_ internally, which has a partIndex
and an offset within the part text, which is more natural to work with.
From there on, the caret _position_ is used, also during reconciliation.

View file

@ -109,7 +109,7 @@ instance. As of writing those settings are not fully documented, however a few a
} }
``` ```
These values will take priority over the hardcoded defaults for the settings. For a list of available settings, see These values will take priority over the hardcoded defaults for the settings. For a list of available settings, see
[Settings.tsx](https://github.com/matrix-org/matrix-react-sdk/blob/develop/src/settings/Settings.tsx). [Settings.tsx](https://github.com/element-hq/element-web/blob/develop/src/settings/Settings.tsx).
## Customisation & branding ## Customisation & branding

View file

@ -12,7 +12,7 @@ Element Web and the React SDK support "customisation points" that can be used to
easily add custom logic specific to a particular deployment of Element Web. easily add custom logic specific to a particular deployment of Element Web.
An example of this is the [security customisations An example of this is the [security customisations
module](https://github.com/matrix-org/matrix-react-sdk/blob/develop/src/customisations/Security.ts). module](https://github.com/element-hq/element-web/blob/develop/src/customisations/Security.ts).
This module in the React SDK only defines some empty functions and their types: This module in the React SDK only defines some empty functions and their types:
it does not do anything by default. it does not do anything by default.
@ -54,7 +54,7 @@ UI for some actions can be hidden via the ComponentVisibility customisation:
- creating rooms, - creating rooms,
- creating spaces, - creating spaces,
To customise visibility create a customisation module from [ComponentVisibility](https://github.com/matrix-org/matrix-react-sdk/blob/master/src/customisations/ComponentVisibility.ts) following the instructions above. To customise visibility create a customisation module from [ComponentVisibility](https://github.com/element-hq/element-web/blob/master/src/customisations/ComponentVisibility.ts) following the instructions above.
`shouldShowComponent` determines whether the active MatrixClient user should be able to use `shouldShowComponent` determines whether the active MatrixClient user should be able to use
the given UI component. When `shouldShowComponent` returns falsy all UI components for that feature will be hidden. the given UI component. When `shouldShowComponent` returns falsy all UI components for that feature will be hidden.

View file

@ -35,7 +35,7 @@ clients commit to doing the associated clean up work once a feature stabilises.
When starting work on a feature, we should create a matching feature flag: When starting work on a feature, we should create a matching feature flag:
1. Add a new 1. Add a new
[setting](https://github.com/matrix-org/matrix-react-sdk/blob/develop/src/settings/Settings.tsx) [setting](https://github.com/element-hq/element-web/blob/develop/src/settings/Settings.tsx)
of the form: of the form:
```js ```js
@ -93,14 +93,14 @@ Once we're confident that a feature is working well, we should remove or convert
If the feature is meant to be turned off/on by the user: If the feature is meant to be turned off/on by the user:
1. Remove `isFeature` from the [setting](https://github.com/matrix-org/matrix-react-sdk/blob/develop/src/settings/Settings.ts) 1. Remove `isFeature` from the [setting](https://github.com/element-hq/element-web/blob/develop/src/settings/Settings.ts)
2. Change the `default` to `true` (if desired). 2. Change the `default` to `true` (if desired).
3. Remove the feature from the [labs documentation](https://github.com/element-hq/element-web/blob/develop/docs/labs.md) 3. Remove the feature from the [labs documentation](https://github.com/element-hq/element-web/blob/develop/docs/labs.md)
4. Celebrate! 🥳 4. Celebrate! 🥳
If the feature is meant to be forced on (non-configurable): If the feature is meant to be forced on (non-configurable):
1. Remove the [setting](https://github.com/matrix-org/matrix-react-sdk/blob/develop/src/settings/Settings.ts) 1. Remove the [setting](https://github.com/element-hq/element-web/blob/develop/src/settings/Settings.ts)
2. Remove all `getValue` lines that test for the feature. 2. Remove all `getValue` lines that test for the feature.
3. Remove the feature from the [labs documentation](https://github.com/element-hq/element-web/blob/develop/docs/labs.md) 3. Remove the feature from the [labs documentation](https://github.com/element-hq/element-web/blob/develop/docs/labs.md)
4. If applicable, remove the feature state from 4. If applicable, remove the feature state from

6
docs/features/README.md Normal file
View file

@ -0,0 +1,6 @@
# Feature documention
The idea of this folder is to document the features we support in different parts of the app.
In case anyone needs to work on a given part, and isn't aware of all the features in the area,
they will hopefully get an idea for all the supported functionality to know what to take into account
when making changes.

38
docs/features/composer.md Normal file
View file

@ -0,0 +1,38 @@
# Composer Features
## Auto Complete
- Hitting tab tries to auto-complete the word before the caret as a room member
- If no matching name is found, a visual bell is shown
- @ + a letter opens auto complete for members starting with the given letter
- When inserting a user pill at the start in the composer, a colon and space is appended to the pill
- When inserting a user pill anywhere else in composer, only a space is appended to the pill
- # + a letter opens auto complete for rooms starting with the given letter
- : open auto complete for emoji
- Pressing arrow-up/arrow-down while the autocomplete is open navigates between auto complete options
- Pressing tab while the autocomplete is open goes to the next autocomplete option,
wrapping around at the end after reverting to the typed text first.
## Formatting
- When selecting text, a formatting bar appears above the selection.
- The formatting bar allows to format the selected test as:
bold, italic, strikethrough, a block quote, and a code block (inline if no linebreak is selected).
- Formatting is applied as markdown syntax.
- Hitting ctrl/cmd+B also marks the selected text as bold
- Hitting ctrl/cmd+I also marks the selected text as italic
- Hitting ctrl/cmd+> also marks the selected text as a blockquote
## Misc
- When hitting the arrow-up button while having the caret at the start in the composer,
the last message sent by the syncing user is edited.
- Clicking a display name on an event in the timeline inserts a user pill into the composer
- Emoticons (like :-), >:-), :-/, ...) are replaced by emojis while typing if the relevant setting is enabled
- Typing in the composer sends typing notifications in the room
- Pressing ctrl/mod+z and ctrl/mod+y undoes/redoes modifications
- Pressing shift+enter inserts a line break
- Pressing enter sends the message.
- Choosing "Quote" in the context menu of an event inserts a quote of the event body in the composer.
- Choosing "Reply" in the context menu of an event shows a preview above the composer to reply to.
- Pressing alt+arrow up/arrow down navigates in previously sent messages, putting them in the composer.

View file

@ -0,0 +1,59 @@
# Keyboard shortcuts
## Using the `KeyBindingManager`
The `KeyBindingManager` (accessible using `getKeyBindingManager()`) is a class
with several methods that allow you to get a `KeyBindingAction` based on a
`KeyboardEvent | React.KeyboardEvent`.
The event passed to the `KeyBindingManager` gets compared to the list of
shortcuts that are retrieved from the `IKeyBindingsProvider`s. The
`IKeyBindingsProvider` is in `KeyBindingDefaults`.
### Examples
Let's say we want to close a menu when the correct keys were pressed:
```ts
const onKeyDown = (ev: KeyboardEvent): void => {
let handled = true;
const action = getKeyBindingManager().getAccessibilityAction(ev);
switch (action) {
case KeyBindingAction.Escape:
closeMenu();
break;
default:
handled = false;
break;
}
if (handled) {
ev.preventDefault();
ev.stopPropagation();
}
};
```
## Managing keyboard shortcuts
There are a few things at play when it comes to keyboard shortcuts. The
`KeyBindingManager` gets `IKeyBindingsProvider`s one of which is
`defaultBindingsProvider` defined in `KeyBindingDefaults`. In
`KeyBindingDefaults` a `getBindingsByCategory()` method is used to create
`KeyBinding`s based on `KeyboardShortcutSetting`s defined in
`KeyboardShortcuts`.
### Adding keyboard shortcuts
To add a keyboard shortcut there are two files we have to look at:
`KeyboardShortcuts.ts` and `KeyBindingDefaults.ts`. In most cases we only need
to edit `KeyboardShortcuts.ts`: add a `KeyBindingAction` and add the
`KeyBindingAction` to the `KEYBOARD_SHORTCUTS` object.
Though, to make matters worse, sometimes we want to add a shortcut that has
multiple keybindings associated with. This keyboard shortcut won't be
customizable as it would be rather difficult to manage both from the point of
the settings and the UI. To do this, we have to add a `KeyBindingAction` and add
the UI representation of that keyboard shortcut to the `getUIOnlyShortcuts()`
method. Then, we also need to add the keybinding to the correct method in
`KeyBindingDefaults`.

56
docs/icons.md Normal file
View file

@ -0,0 +1,56 @@
# Icons
Icons are loaded using [@svgr/webpack](https://www.npmjs.com/package/@svgr/webpack).
This is configured in [element-web](https://github.com/vector-im/element-web/blob/develop/webpack.config.js#L458).
Each `.svg` exports a `ReactComponent` at the named export `Icon`.
Icons have `role="presentation"` and `aria-hidden` automatically applied. These can be overriden by passing props to the icon component.
SVG file recommendations:
- Colours should not be defined absolutely. Use `currentColor` instead.
- SVG files should be taken from the design compound as they are. Some icons contain special padding.
This means that there should be icons for each size, e.g. warning-16px and warning-32px.
Example usage:
```
import { Icon as FavoriteIcon } from 'res/img/element-icons/favorite.svg';
const MyComponent = () => {
return <>
<FavoriteIcon className="mx_Icon mx_Icon_16">
</>;
}
```
If possible, use the icon classes from [here](../res/css/compound/_Icon.pcss).
## Custom styling
Icon components are svg elements and may be custom styled as usual.
`_MyComponents.pcss`:
```css
.mx_MyComponent-icon {
height: 20px;
width: 20px;
* {
fill: $accent;
}
}
```
`MyComponent.tsx`:
```typescript
import { Icon as FavoriteIcon } from 'res/img/element-icons/favorite.svg';
const MyComponent = () => {
return <>
<FavoriteIcon className="mx_MyComponent-icon" role="img" aria-hidden="false">
</>;
}
```

BIN
docs/img/RoomListStore2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View file

@ -67,8 +67,6 @@ element-web branch and then run:
```bash ```bash
docker build -t \ docker build -t \
--build-arg USE_CUSTOM_SDKS=true \ --build-arg USE_CUSTOM_SDKS=true \
--build-arg REACT_SDK_REPO="https://github.com/matrix-org/matrix-react-sdk.git" \
--build-arg REACT_SDK_BRANCH="develop" \
--build-arg JS_SDK_REPO="https://github.com/matrix-org/matrix-js-sdk.git" \ --build-arg JS_SDK_REPO="https://github.com/matrix-org/matrix-js-sdk.git" \
--build-arg JS_SDK_BRANCH="develop" \ --build-arg JS_SDK_BRANCH="develop" \
. .

View file

@ -70,3 +70,41 @@ The domain used is the one specified by the `/.well-known/matrix/client` endpoin
For active Jitsi widgets in the room, a native Jitsi widget UI is created and points to the instance specified in the `domain` key of the widget content data. For active Jitsi widgets in the room, a native Jitsi widget UI is created and points to the instance specified in the `domain` key of the widget content data.
Element Android manages allowed native widgets permissions a bit differently than web widgets (as the data shared are different and never shared with the widget URL). For Jitsi widgets, permissions are requested only once per domain (consent saved in account data). Element Android manages allowed native widgets permissions a bit differently than web widgets (as the data shared are different and never shared with the widget URL). For Jitsi widgets, permissions are requested only once per domain (consent saved in account data).
# Jitsi Wrapper
**Note**: These are developer docs. Please consult your client's documentation for
instructions on setting up Jitsi.
The react-sdk wraps all Jitsi call widgets in a local wrapper called `jitsi.html`
which takes several parameters:
_Query string_:
- `widgetId`: The ID of the widget. This is needed for communication back to the
react-sdk.
- `parentUrl`: The URL of the parent window. This is also needed for
communication back to the react-sdk.
_Hash/fragment (formatted as a query string)_:
- `conferenceDomain`: The domain to connect Jitsi Meet to.
- `conferenceId`: The room or conference ID to connect Jitsi Meet to.
- `isAudioOnly`: Boolean for whether this is a voice-only conference. May not
be present, should default to `false`.
- `startWithAudioMuted`: Boolean for whether the calls start with audio
muted. May not be present.
- `startWithVideoMuted`: Boolean for whether the calls start with video
muted. May not be present.
- `displayName`: The display name of the user viewing the widget. May not
be present or could be null.
- `avatarUrl`: The HTTP(S) URL for the avatar of the user viewing the widget. May
not be present or could be null.
- `userId`: The MXID of the user viewing the widget. May not be present or could
be null.
The react-sdk will assume that `jitsi.html` is at the path of wherever it is currently
being served. For example, `https://develop.element.io/jitsi.html` or `vector://webapp/jitsi.html`.
The `jitsi.html` wrapper can use the react-sdk's `WidgetApi` to communicate, making
it easier to actually implement the feature.

View file

@ -72,11 +72,6 @@ theme definition.
For some sample themes, check out [aaronraimist/element-themes](https://github.com/aaronraimist/element-themes). For some sample themes, check out [aaronraimist/element-themes](https://github.com/aaronraimist/element-themes).
## Dehydrated devices (`feature_dehydration`)
Allows users to receive encrypted messages by creating a device that is stored
encrypted on the server, as described in [MSC2697](https://github.com/matrix-org/matrix-doc/pull/2697).
## Live location sharing (`feature_location_share_live`) [In Development] ## Live location sharing (`feature_location_share_live`) [In Development]
Enables sharing your current location to the timeline, with live updates. Enables sharing your current location to the timeline, with live updates.

38
docs/local-echo-dev.md Normal file
View file

@ -0,0 +1,38 @@
# Local echo (developer docs)
The React SDK provides some local echo functionality to allow for components to do something
quickly and fall back when it fails. This is all available in the `local-echo` directory within
`stores`.
Echo is handled in EchoChambers, with `GenericEchoChamber` being the base implementation for all
chambers. The `EchoChamber` class is provided as semantic access to a `GenericEchoChamber`
implementation, such as the `RoomEchoChamber` (which handles echoable details of a room).
Anything that can be locally echoed will be provided by the `GenericEchoChamber` implementation.
The echo chamber will also need to deal with external changes, and has full control over whether
or not something has successfully been echoed.
An `EchoContext` is provided to echo chambers (usually with a matching type: `RoomEchoContext`
gets provided to a `RoomEchoChamber` for example) with details about their intended area of
effect, as well as manage `EchoTransaction`s. An `EchoTransaction` is simply a unit of work that
needs to be locally echoed.
The `EchoStore` manages echo chamber instances, builds contexts, and is generally less semantically
accessible than the `EchoChamber` class. For separation of concerns, and to try and keep things
tidy, this is an intentional design decision.
**Note**: The local echo stack uses a "whenable" pattern, which is similar to thenables and
`EventEmitter`. Whenables are ways of actioning a changing condition without having to deal
with listeners being torn down. Once the reference count of the Whenable causes garbage collection,
the Whenable's listeners will also be torn down. This is accelerated by the `IDestroyable` interface
usage.
## Audit functionality
The UI supports a "Server isn't responding" dialog which includes a partial audit log-like
structure to it. This is partially the reason for added complexity of `EchoTransaction`s
and `EchoContext`s - this information feeds the UI states which then provide direct retry
mechanisms.
The `EchoStore` is responsible for ensuring that the appropriate non-urgent toast (lower left)
is set up, where the dialog then drives through the contexts and transactions.

19
docs/media-handling.md Normal file
View file

@ -0,0 +1,19 @@
# Media handling
Surely media should be as easy as just putting a URL into an `img` and calling it good, right?
Not quite. Matrix uses something called a Matrix Content URI (better known as MXC URI) to identify
content, which is then converted to a regular HTTPS URL on the homeserver. However, sometimes that
URL can change depending on deployment considerations.
The react-sdk features a [customisation endpoint](https://github.com/vector-im/element-web/blob/develop/docs/customisations.md)
for media handling where all conversions from MXC URI to HTTPS URL happen. This is to ensure that
those obscure deployments can route all their media to the right place.
For development, there are currently two functions available: `mediaFromMxc` and `mediaFromContent`.
The `mediaFromMxc` function should be self-explanatory. `mediaFromContent` takes an event content as
a parameter and will automatically parse out the source media and thumbnail. Both functions return
a `Media` object with a number of options on it, such as getting various common HTTPS URLs for the
media.
**It is extremely important that all media calls are put through this customisation endpoint.** So
much so it's a lint rule to avoid accidental use of the wrong functions.

View file

@ -23,11 +23,11 @@ the current directory as the build context (the `.` in `docker build -t my-eleme
## Writing modules ## Writing modules
While writing modules is meant to be easy, not everything is possible yet. For modules which want to do something we haven't While writing modules is meant to be easy, not everything is possible yet. For modules which want to do something we haven't
exposed in the module API, the module API will need to be updated. This means a PR to both the exposed in the module API, the module API will need to be updated. This means a PR to both this repo
[`matrix-react-sdk`](https://github.com/matrix-org/matrix-react-sdk) and [`matrix-react-sdk-module-api`](https://github.com/matrix-org/matrix-react-sdk-module-api). and [`matrix-react-sdk-module-api`](https://github.com/matrix-org/matrix-react-sdk-module-api).
Once your change to the module API is accepted, the `@matrix-org/react-sdk-module-api` dependency gets updated at the Once your change to the module API is accepted, the `@matrix-org/react-sdk-module-api` dependency gets updated at the
`matrix-react-sdk` and `element-web` layers (usually by us, the maintainers) to ensure your module can operate. `element-web` layer (usually by us, the maintainers) to ensure your module can operate.
If you're not adding anything to the module API, or your change was accepted per above, then start off with a clone of If you're not adding anything to the module API, or your change was accepted per above, then start off with a clone of
our [ILAG module](https://github.com/element-hq/element-web-ilag-module) which will give you a general idea for what the our [ILAG module](https://github.com/element-hq/element-web-ilag-module) which will give you a general idea for what the

219
docs/playwright.md Normal file
View file

@ -0,0 +1,219 @@
# Playwright in Element Web
## Contents
- How to run the tests
- How the tests work
- How to write great Playwright tests
- Visual testing
## Running the Tests
Our Playwright tests run automatically as part of our CI along with our other tests,
on every pull request and on every merge to develop & master.
You may need to follow instructions to set up your development environment for running
Playwright by following <https://playwright.dev/docs/browsers#install-browsers> and
<https://playwright.dev/docs/browsers#install-system-dependencies>.
However the Playwright tests are run, an element-web instance must be running on
http://localhost:8080 (this is configured in `playwright.config.ts`) - this is what will
be tested. When running Playwright tests yourself, the standard `yarn start` from the
element-web project is fine: leave it running it a different terminal as you would
when developing. Alternatively if you followed the development set up from element-web then
Playwright will be capable of running the webserver on its own if it isn't already running.
The tests use Docker to launch Homeserver (Synapse or Dendrite) instances to test against, so you'll also
need to have Docker installed and working in order to run the Playwright tests.
There are a few different ways to run the tests yourself. The simplest is to run:
```shell
docker pull ghcr.io/element-hq/synapse:develop
yarn run test:playwright
```
This will run the Playwright tests once, non-interactively.
Note: you don't need to run the `docker pull` command every time, but you should
do it regularly to ensure you are running against an up-to-date Synapse.
You can also run individual tests this way too, as you'd expect:
```shell
yarn run test:playwright --spec playwright/e2e/register/register.spec.ts
```
Playwright also has its own UI that you can use to run and debug the tests.
To launch it:
```shell
yarn run test:playwright:open --headed --debug
```
See more command line options at <https://playwright.dev/docs/test-cli>.
### Running with Rust cryptography
`matrix-js-sdk` is currently in the
[process](https://github.com/vector-im/element-web/issues/21972) of being
updated to replace its end-to-end encryption implementation to use the [Matrix
Rust SDK](https://github.com/matrix-org/matrix-rust-sdk). This is not currently
enabled by default, but it is possible to have Playwright configure Element to use
the Rust crypto implementation by passing `--project="Rust Crypto"` or using
the top left options in open mode.
## How the Tests Work
Everything Playwright-related lives in the `playwright/` subdirectory of react-sdk
as is typical for Playwright tests. Likewise, tests live in `playwright/e2e`.
`playwright/plugins/homeservers` contains Playwright plugins that starts instances
of Synapse/Dendrite in Docker containers. These servers are what Element-web runs
against in the tests.
Synapse can be launched with different configurations in order to test element
in different configurations. `playwright/plugins/homeserver/synapse/templates`
contains template configuration files for each different configuration.
Each test suite can then launch whatever Synapse instances it needs in whatever
configurations.
Note that although tests should stop the Homeserver instances after running and the
plugin also stop any remaining instances after all tests have run, it is possible
to be left with some stray containers if, for example, you terminate a test such
that the `after()` does not run and also exit Playwright uncleanly. All the containers
it starts are prefixed, so they are easy to recognise. They can be removed safely.
After each test run, logs from the Synapse instances are saved in `playwright/logs/synapse`
with each instance in a separate directory named after its ID. These logs are removed
at the start of each test run.
## Writing Tests
Mostly this is the same advice as for writing any other Playwright test: the Playwright
docs are well worth a read if you're not already familiar with Playwright testing, eg.
https://playwright.dev/docs/best-practices. To avoid your tests being flaky it is also
recommended to use [auto-retrying assertions](https://playwright.dev/docs/test-assertions#auto-retrying-assertions).
### Getting a Synapse
We heavily leverage the magic of [Playwright fixtures](https://playwright.dev/docs/test-fixtures).
To acquire a homeserver within a test just add the `homeserver` fixture to the test:
```typescript
test("should do something", async ({ homeserver }) => {
// homeserver is a Synapse/Dendrite instance
});
```
This returns an object with information about the Homeserver instance, including what port
it was started on and the ID that needs to be passed to shut it down again. It also
returns the registration shared secret (`registrationSecret`) that can be used to
register users via the REST API. The Homeserver has been ensured ready to go by awaiting
its internal health-check.
Homeserver instances should be reasonably cheap to start (you may see the first one take a
while as it pulls the Docker image).
You do not need to explicitly clean up the instance as it will be cleaned up by the fixture.
### Synapse Config Templates
When a Synapse instance is started, it's given a config generated from one of the config
templates in `playwright/plugins/homeserver/synapse/templates`. There are a couple of special files
in these templates:
- `homeserver.yaml`:
Template substitution happens in this file. Template variables are:
- `REGISTRATION_SECRET`: The secret used to register users via the REST API.
- `MACAROON_SECRET_KEY`: Generated each time for security
- `FORM_SECRET`: Generated each time for security
- `PUBLIC_BASEURL`: The localhost url + port combination the synapse is accessible at
- `localhost.signing.key`: A signing key is auto-generated and saved to this file.
Config templates should not contain a signing key and instead assume that one will exist
in this file.
All other files in the template are copied recursively to `/data/`, so the file `foo.html`
in a template can be referenced in the config as `/data/foo.html`.
### Logging In
We again heavily leverage the magic of [Playwright fixtures](https://playwright.dev/docs/test-fixtures).
To acquire a logged-in user within a test just add the `user` fixture to the test:
```typescript
test("should do something", async ({ user }) => {
// user is a logged in user
});
```
You can specify a display name for the user via `test.use` `displayName`,
otherwise a random one will be generated.
This will register a random userId using the registrationSecret with a random password
and the given display name. The user fixture will contain details about the credentials for if
they are needed for User-Interactive Auth or similar but localStorage will already be seeded with them
and the app loaded (path `/`).
### Joining a Room
Many tests will also want to start with the client in a room, ready to send & receive messages. Best
way to do this may be to get an access token for the user and use this to create a room with the REST
API before logging the user in.
You can make use of the bot fixture and the `client` field on the app fixture to do this.
### Try to write tests from the users' perspective
Like for instance a user will not look for a button by querying a CSS selector.
Instead, you should work with roles / labels etc, see https://playwright.dev/docs/locators.
### Using matrix-js-sdk
Due to the way we run the Playwright tests in CI, at this time you can only use the matrix-js-sdk module
exposed on `window.matrixcs`. This has the limitation that it is only accessible with the app loaded.
This may be revisited in the future.
## Good Test Hygiene
This section mostly summarises general good Playwright testing practice, and should not be news to anyone
already familiar with Playwright.
1. Test a well-isolated unit of functionality. The more specific, the easier it will be to tell what's
wrong when they fail.
1. Don't depend on state from other tests: any given test should be able to run in isolation.
1. Try to avoid driving the UI for anything other than the UI you're trying to test. e.g. if you're
testing that the user can send a reaction to a message, it's best to send a message using a REST
API, then react to it using the UI, rather than using the element-web UI to send the message.
1. Avoid explicit waits. Playwright locators & assertions will implicitly wait for the specified
element to appear and all assertions are retried until they either pass or time out, so you should
never need to manually wait for an element.
- For example, for asserting about editing an already-edited message, you can't wait for the
'edited' element to appear as there was already one there, but you can assert that the body
of the message is what is should be after the second edit and this assertion will pass once
it becomes true. You can then assert that the 'edited' element is still in the DOM.
- You can also wait for other things like network requests in the
browser to complete (https://playwright.dev/docs/api/class-page#page-wait-for-response).
Needing to wait for things can also be because of race conditions in the app itself, which ideally
shouldn't be there!
This is a small selection - the Playwright best practices guide, linked above, has more good advice, and we
should generally try to adhere to them.
## Screenshot testing
When we previously used Cypress we also dabbled with Percy, and whilst powerful it did not
lend itself well to being executed on all PRs without needing to budget it substantially.
Playwright has built-in support for [visual comparison testing](https://playwright.dev/docs/test-snapshots).
Screenshots are saved in `playwright/snapshots` and are rendered in a Linux Docker environment for stability.
One must be careful to exclude any dynamic content from the screenshot, such as timestamps, avatars, etc,
via the `mask` option. See the [Playwright docs](https://playwright.dev/docs/test-snapshots#masking).
Some UI elements render differently between test runs, such as BaseAvatar when
there is no avatar set, choosing a colour from the theme palette based on the
hash of the user/room's Matrix ID. To avoid this creating flaky tests we inject
some custom CSS, for this to happen we use the custom assertion `toMatchScreenshot`
instead of the native `toHaveScreenshot`.
If you are running Linux and are unfortunate that the screenshots are not rendering identically,
you may wish to specify `--ignore-snapshots` and rely on Docker to render them for you.

View file

@ -22,7 +22,7 @@ The master branch is the most stable as it is the very latest non-RC release. De
<details><summary><h1>Versions</h1></summary><blockquote> <details><summary><h1>Versions</h1></summary><blockquote>
The matrix-js-sdk follows semver, the matrix-react-sdk loosely follows semver, most releases for both will bump the minor version number. The matrix-js-sdk follows semver, most releases will bump the minor version number.
Breaking changes will bump the major version number. Breaking changes will bump the major version number.
Element Web & Element Desktop do not follow semver and always have matching version numbers. The patch version number is normally incremented for every release. Element Web & Element Desktop do not follow semver and always have matching version numbers. The patch version number is normally incremented for every release.
@ -80,11 +80,10 @@ This label will automagically convert to `X-Release-Blocker` at the conclusion o
<details><summary><h1>Repositories</h1></summary><blockquote> <details><summary><h1>Repositories</h1></summary><blockquote>
This release process revolves around our four main repositories: This release process revolves around our main repositories:
- [Element Desktop](https://github.com/element-hq/element-desktop/) - [Element Desktop](https://github.com/element-hq/element-desktop/)
- [Element Web](https://github.com/element-hq/element-web/) - [Element Web](https://github.com/element-hq/element-web/)
- [Matrix React SDK](https://github.com/matrix-org/matrix-react-sdk/)
- [Matrix JS SDK](https://github.com/matrix-org/matrix-js-sdk/) - [Matrix JS SDK](https://github.com/matrix-org/matrix-js-sdk/)
We own other repositories, but they have more ad-hoc releases and are not part of the bi-weekly cycle: We own other repositories, but they have more ad-hoc releases and are not part of the bi-weekly cycle:
@ -117,14 +116,13 @@ flowchart TD
subgraph Releasing subgraph Releasing
R1[[Releasing matrix-js-sdk]] R1[[Releasing matrix-js-sdk]]
R2[[Releasing matrix-react-sdk]] R2[[Releasing element-web]]
R3[[Releasing element-web]] R3[[Releasing element-desktop]]
R4[[Releasing element-desktop]]
R1 --> R2 --> R3 --> R4 R1 --> R2 --> R3
end end
R4 --> D1 R3 --> D1
subgraph Deploying subgraph Deploying
D1[\Deploy staging.element.io/] D1[\Deploy staging.element.io/]
@ -198,12 +196,6 @@ switched back to the version of the dependency from the master branch to not lea
- [ ] Make any changes to the release notes in the draft release as are necessary - **Do not click publish, only save draft** - [ ] Make any changes to the release notes in the draft release as are necessary - **Do not click publish, only save draft**
- [ ] Kick off a release using [the automation](https://github.com/matrix-org/matrix-js-sdk/actions/workflows/release.yml) - making sure to select the right type of release. For anything other than an RC: choose final. You should not need to ever switch off either of the Publishing options. - [ ] Kick off a release using [the automation](https://github.com/matrix-org/matrix-js-sdk/actions/workflows/release.yml) - making sure to select the right type of release. For anything other than an RC: choose final. You should not need to ever switch off either of the Publishing options.
### Matrix React SDK
- [ ] Check the draft release which has been generated by [the automation](https://github.com/element-hq/matrix-react-sdk/actions/workflows/release-drafter.yml)
- [ ] Make any changes to the release notes in the draft release as are necessary - **Do not click publish, only save draft**
- [ ] Kick off a release using [the automation](https://github.com/element-hq/matrix-react-sdk/actions/workflows/release.yml) - making sure to select the right type of release. For anything other than an RC: choose final. You should not need to ever switch off either of the Publishing options.
### Element Web ### Element Web
- [ ] Check the draft release which has been generated by [the automation](https://github.com/element-hq/element-web/actions/workflows/release-drafter.yml) - [ ] Check the draft release which has been generated by [the automation](https://github.com/element-hq/element-web/actions/workflows/release-drafter.yml)
@ -256,8 +248,6 @@ For the first RC of a given release cycle do these steps:
- [ ] Go to the [matrix-js-sdk Renovate dashboard](https://github.com/matrix-org/matrix-js-sdk/issues/2406) and click the checkbox to create/update its PRs. - [ ] Go to the [matrix-js-sdk Renovate dashboard](https://github.com/matrix-org/matrix-js-sdk/issues/2406) and click the checkbox to create/update its PRs.
- [ ] Go to the [matrix-react-sdk Renovate dashboard](https://github.com/element-hq/matrix-react-sdk/issues/6) and click the checkbox to create/update its PRs.
- [ ] Go to the [element-web Renovate dashboard](https://github.com/element-hq/element-web/issues/22941) and click the checkbox to create/update its PRs. - [ ] Go to the [element-web Renovate dashboard](https://github.com/element-hq/element-web/issues/22941) and click the checkbox to create/update its PRs.
- [ ] Go to the [element-desktop Renovate dashboard](https://github.com/element-hq/element-desktop/issues/465) and click the checkbox to create/update its PRs. - [ ] Go to the [element-desktop Renovate dashboard](https://github.com/element-hq/element-desktop/issues/465) and click the checkbox to create/update its PRs.

165
docs/room-list-store.md Normal file
View file

@ -0,0 +1,165 @@
# Room list sorting
It's so complicated it needs its own README.
![](img/RoomListStore2.png)
Legend:
- Orange = External event.
- Purple = Deterministic flow.
- Green = Algorithm definition.
- Red = Exit condition/point.
- Blue = Process definition.
## Algorithms involved
There's two main kinds of algorithms involved in the room list store: list ordering and tag sorting.
Throughout the code an intentional decision has been made to call them the List Algorithm and Sorting
Algorithm respectively. The list algorithm determines the primary ordering of a given tag whereas the
tag sorting defines how rooms within that tag get sorted, at the discretion of the list ordering.
Behaviour of the overall room list (sticky rooms, etc) are determined by the generically-named Algorithm
class. Here is where much of the coordination from the room list store is done to figure out which list
algorithm to call, instead of having all the logic in the room list store itself.
Tag sorting is effectively the comparator supplied to the list algorithm. This gives the list algorithm
the power to decide when and how to apply the tag sorting, if at all. For example, the importance algorithm,
later described in this document, heavily uses the list ordering behaviour to break the tag into categories.
Each category then gets sorted by the appropriate tag sorting algorithm.
### Tag sorting algorithm: Alphabetical
When used, rooms in a given tag will be sorted alphabetically, where the alphabet's order is a problem
for the browser. All we do is a simple string comparison and expect the browser to return something
useful.
### Tag sorting algorithm: Manual
Manual sorting makes use of the `order` property present on all tags for a room, per the
[Matrix specification](https://matrix.org/docs/spec/client_server/r0.6.0#room-tagging). Smaller values
of `order` cause rooms to appear closer to the top of the list.
### Tag sorting algorithm: Recent
Rooms get ordered by the timestamp of the most recent useful message. Usefulness is yet another algorithm
in the room list system which determines whether an event type is capable of bubbling up in the room list.
Normally events like room messages, stickers, and room security changes will be considered useful enough
to cause a shift in time.
Note that this is reliant on the event timestamps of the most recent message. Because Matrix is eventually
consistent this means that from time to time a room might plummet or skyrocket across the tag due to the
timestamp contained within the event (generated server-side by the sender's server).
### List ordering algorithm: Natural
This is the easiest of the algorithms to understand because it does essentially nothing. It imposes no
behavioural changes over the tag sorting algorithm and is by far the simplest way to order a room list.
Historically, it's been the only option in Element and extremely common in most chat applications due to
its relative deterministic behaviour.
### List ordering algorithm: Importance
On the other end of the spectrum, this is the most complicated algorithm which exists. There's major
behavioural changes, and the tag sorting algorithm gets selectively applied depending on circumstances.
Each tag which is not manually ordered gets split into 4 sections or "categories". Manually ordered tags
simply get the manual sorting algorithm applied to them with no further involvement from the importance
algorithm. There are 4 categories: Red, Grey, Bold, and Idle. Each has their own definition based off
relative (perceived) importance to the user:
- **Red**: The room has unread mentions waiting for the user.
- **Grey**: The room has unread notifications waiting for the user. Notifications are simply unread
messages which cause a push notification or badge count. Typically, this is the default as rooms get
set to 'All Messages'.
- **Bold**: The room has unread messages waiting for the user. Essentially this is a grey room without
a badge/notification count (or 'Mentions Only'/'Muted').
- **Idle**: No useful (see definition of useful above) activity has occurred in the room since the user
last read it.
Conveniently, each tag gets ordered by those categories as presented: red rooms appear above grey, grey
above bold, etc.
Once the algorithm has determined which rooms belong in which categories, the tag sorting algorithm
gets applied to each category in a sub-list fashion. This should result in the red rooms (for example)
being sorted alphabetically amongst each other as well as the grey rooms sorted amongst each other, but
collectively the tag will be sorted into categories with red being at the top.
## Sticky rooms
When the user visits a room, that room becomes 'sticky' in the list, regardless of ordering algorithm.
From a code perspective, the underlying algorithm is not aware of a sticky room and instead the base class
manages which room is sticky. This is to ensure that all algorithms handle it the same.
The sticky flag is simply to say it will not move higher or lower down the list while it is active. For
example, if using the importance algorithm, the room would naturally become idle once viewed and thus
would normally fly down the list out of sight. The sticky room concept instead holds it in place, never
letting it fly down until the user moves to another room.
Only one room can be sticky at a time. Room updates around the sticky room will still hold the sticky
room in place. The best example of this is the importance algorithm: if the user has 3 red rooms and
selects the middle room, they will see exactly one room above their selection at all times. If they
receive another notification which causes the room to move into the topmost position, the room that was
above the sticky room will move underneath to allow for the new room to take the top slot, maintaining
the sticky room's position.
Though only applicable to the importance algorithm, the sticky room is not aware of category boundaries
and thus the user can see a shift in what kinds of rooms move around their selection. An example would
be the user having 4 red rooms, the user selecting the third room (leaving 2 above it), and then having
the rooms above it read on another device. This would result in 1 red room and 1 other kind of room
above the sticky room as it will try to maintain 2 rooms above the sticky room.
An exception for the sticky room placement is when there's suddenly not enough rooms to maintain the placement
exactly. This typically happens if the user selects a room and leaves enough rooms where it cannot maintain
the N required rooms above the sticky room. In this case, the sticky room will simply decrease N as needed.
The N value will never increase while selection remains unchanged: adding a bunch of rooms after having
put the sticky room in a position where it's had to decrease N will not increase N.
## Responsibilities of the store
The store is responsible for the ordering, upkeep, and tracking of all rooms. The room list component simply gets
an object containing the tags it needs to worry about and the rooms within. The room list component will
decide which tags need rendering (as it commonly filters out empty tags in most cases), and will deal with
all kinds of filtering.
## Filtering
Filters are provided to the store as condition classes and have two major kinds: Prefilters and Runtime.
Prefilters flush out rooms which shouldn't appear to the algorithm implementations. Typically this is
due to some higher order room list filtering (such as spaces or tags) deliberately exposing a subset of
rooms to the user. The algorithm implementations will not see a room being prefiltered out.
Runtime filters are used for more dynamic filtering, such as the user filtering by room name. These
filters are passed along to the algorithm implementations where those implementations decide how and
when to apply the filter. In practice, the base `Algorithm` class ends up doing the heavy lifting for
optimization reasons.
The results of runtime filters get cached to avoid needlessly iterating over potentially thousands of
rooms, as the old room list store does. When a filter condition changes, it emits an update which (in this
case) the `Algorithm` class will pick up and act accordingly. Typically, this also means filtering a
minor subset where possible to avoid over-iterating rooms.
All filter conditions are considered "stable" by the consumers, meaning that the consumer does not
expect a change in the condition unless the condition says it has changed. This is intentional to
maintain the caching behaviour described above.
One might ask why we don't just use prefilter conditions for everything, and the answer is one of slight
subtlety: in the cases of prefilters we are knowingly exposing the user to a workspace-style UX where
room notifications are self-contained within that workspace. Runtime filters tend to not want to affect
visible notification counts (as it doesn't want the room header to suddenly be confusing to the user as
they type), and occasionally UX like "found 2/12 rooms" is desirable. If prefiltering were used instead,
the notification counts would vary while the user was typing and "found 2/12" UX would not be possible.
## Class breakdowns
The `RoomListStore` is the major coordinator of various algorithm implementations, which take care
of the various `ListAlgorithm` and `SortingAlgorithm` options. The `Algorithm` class is responsible
for figuring out which tags get which rooms, as Matrix specifies them as a reverse map: tags get
defined on rooms and are not defined as a collection of rooms (unlike how they are presented to the
user). Various list-specific utilities are also included, though they are expected to move somewhere
more general when needed. For example, the `membership` utilities could easily be moved elsewhere
as needed.
The various bits throughout the room list store should also have jsdoc of some kind to help describe
what they do and how they work.

27
docs/scrolling.md Normal file
View file

@ -0,0 +1,27 @@
# ScrollPanel
## Updates
During an onscroll event, we check whether we're getting close to the top or bottom edge of the loaded content. If close enough, we fire a request to load more through the callback passed in the `onFillRequest` prop. This returns a promise is passed down from `TimelinePanel`, where it will call paginate on the `TimelineWindow` and once the events are received back, update its state with the new events. This update trickles down to the `MessagePanel`, which rerenders all tiles and passed that to `ScrollPanel`. ScrollPanels `componentDidUpdate` method gets called, and we do the scroll housekeeping there (read below). Once the rerender has completed, the `setState` callback is called and we resolve the promise returned by `onFillRequest`. Now we check the DOM to see if we need more fill requests.
## Prevent Shrinking
ScrollPanel supports a mode to prevent it shrinking. This is used to prevent a jump when at the bottom of the timeline and people start and stop typing. It gets cleared automatically when 200px above the bottom of the timeline.
## BACAT (Bottom-Aligned, Clipped-At-Top) scrolling
BACAT scrolling implements a different way of restoring the scroll position in the timeline while tiles out of view are changing height or tiles are being added or removed. It was added in https://github.com/matrix-org/matrix-react-sdk/pull/2842.
The motivation for the changes is having noticed that setting scrollTop while scrolling tends to not work well, with it interrupting ongoing scrolling and also querying scrollTop reporting outdated values and consecutive scroll adjustments cancelling each out previous ones. This seems to be worse on macOS than other platforms, presumably because of a higher resolution in scroll events there. Also see https://github.com/vector-im/element-web/issues/528. The BACAT approach allows to only have to change the scroll offset when adding or removing tiles.
The approach taken instead is to vertically align the timeline tiles to the bottom of the scroll container (using flexbox) and give the timeline inside the scroll container an explicit height, initially set to a multiple of the PAGE_SIZE (400px at time of writing) as needed by the content. When scrolled up, we can compensate for anything that grew below the viewport by changing the height of the timeline to maintain what's currently visible in the viewport without adjusting the scrollTop and hence without jumping.
For anything above the viewport growing or shrinking, we don't need to do anything as the timeline is bottom-aligned. We do need to update the height manually to keep all content visible as more is loaded. To maintain scroll position after the portion above the viewport changes height, we need to set the scrollTop, as we cannot balance it out with more height changes. We do this 100ms after the user has stopped scrolling, so setting scrollTop has not nasty side-effects.
As of https://github.com/matrix-org/matrix-react-sdk/pull/4166, we are scrolling to compensate for height changes by calling `scrollBy(0, x)` rather than reading and than setting `scrollTop`, as reading `scrollTop` can (again, especially on macOS) easily return values that are out of sync with what is on the screen, probably because scrolling can be done [off the main thread](https://wiki.mozilla.org/Platform/GFX/APZ) in some circumstances. This seems to further prevent jumps.
### How does it work?
`componentDidUpdate` is called when a tile in the timeline is updated (as we rerender the whole timeline) or tiles are added or removed (see Updates section before). From here, `checkScroll` is called, which calls `restoreSavedScrollState`. Now, we increase the timeline height if something below the viewport grew by adjusting `this.bottomGrowth`. `bottomGrowth` is the height added to the timeline (on top of the height from the number of pages calculated at the last `updateHeight` run) to compensate for growth below the viewport. This is cleared during the next run of `updateHeight`. Remember that the tiles in the timeline are aligned to the bottom.
From `restoreSavedScrollState` we also call `updateHeight` which waits until the user stops scrolling for 100ms and then recalculates the amount of pages of 400px the timeline should be sized to, to be able to show all of its (newly added) content. We have to adjust the scroll offset (which is why we wait until scrolling has stopped) now because the space above the viewport has likely changed.

236
docs/settings.md Normal file
View file

@ -0,0 +1,236 @@
# Settings Reference
This document serves as developer documentation for using "Granular Settings". Granular Settings allow users to specify
different values for a setting at particular levels of interest. For example, a user may say that in a particular room
they want URL previews off, but in all other rooms they want them enabled. The `SettingsStore` helps mask the complexity
of dealing with the different levels and exposes easy to use getters and setters.
## Levels
Granular Settings rely on a series of known levels in order to use the correct value for the scenario. These levels, in
order of priority, are:
- `device` - The current user's device
- `room-device` - The current user's device, but only when in a specific room
- `room-account` - The current user's account, but only when in a specific room
- `account` - The current user's account
- `room` - A specific room (setting for all members of the room)
- `config` - Values are defined by the `setting_defaults` key (usually) in `config.json`
- `default` - The hardcoded default for the settings
Individual settings may control which levels are appropriate for them as part of the defaults. This is often to ensure
that room administrators cannot force account-only settings upon participants.
## Settings
Settings are the different options a user may set or experience in the application. These are pre-defined in
`src/settings/Settings.tsx` under the `SETTINGS` constant, and match the `ISetting` interface as defined there.
Settings that support the config level can be set in the config file under the `setting_defaults` key (note that some
settings, like the "theme" setting, are special cased in the config file):
```json5
{
...
"setting_defaults": {
"settingName": true
},
...
}
```
### Getting values for a setting
After importing `SettingsStore`, simply make a call to `SettingsStore.getValue`. The `roomId` parameter should always
be supplied where possible, even if the setting does not have a per-room level value. This is to ensure that the value
returned is best represented in the room, particularly if the setting ever gets a per-room level in the future.
In settings pages it is often desired to have the value at a particular level instead of getting the calculated value.
Call `SettingsStore.getValueAt` to get the value of a setting at a particular level, and optionally make it explicitly
at that level. By default `getValueAt` will traverse the tree starting at the provided level; making it explicit means
it will not go beyond the provided level. When using `getValueAt`, please be sure to use `SettingLevel` to represent the
target level.
### Setting values for a setting
Values are defined at particular levels and should be done in a safe manner. There are two checks to perform to ensure a
clean save: is the level supported and can the user actually set the value. In most cases, neither should be an issue
although there are circumstances where this changes. An example of a safe call is:
```javascript
const isSupported = SettingsStore.isLevelSupported(SettingLevel.ROOM);
if (isSupported) {
const canSetValue = SettingsStore.canSetValue("mySetting", "!curbf:matrix.org", SettingLevel.ROOM);
if (canSetValue) {
SettingsStore.setValue("mySetting", "!curbf:matrix.org", SettingLevel.ROOM, newValue);
}
}
```
These checks may also be performed in different areas of the application to avoid the verbose example above. For
instance, the component which allows changing the setting may be hidden conditionally on the above conditions.
##### `SettingsFlag` component
Where possible, the `SettingsFlag` component should be used to set simple "flip-a-bit" (true/false) settings. The
`SettingsFlag` also supports simple radio button options, such as the theme the user would like to use.
```TSX
<SettingsFlag name="theSettingId" level={SettingsLevel.ROOM} roomId="!curbf:matrix.org"
label={_td("Your label here")} // optional, if falsey then the `SettingsStore` will be used
onChange={function(newValue) { }} // optional, called after saving
isExplicit={false} // this is passed along to `SettingsStore.getValueAt`, defaulting to false
manualSave={false} // if true, saving is delayed. You will need to call .save() on this component
// Options for radio buttons
group="your-radio-group" // this enables radio button support
value="yourValueHere" // the value for this particular option
/>
```
### Getting the display name for a setting
Simply call `SettingsStore.getDisplayName`. The appropriate display name will be returned and automatically translated
for you. If a display name cannot be found, it will return `null`.
## Features
Feature flags are just like regular settings with some underlying semantics for how they are meant to be used. Usually
a feature flag is used when a portion of the application is under development or not ready for full release yet, such
as new functionality or experimental ideas. In these cases, the feature name _should_ be named with the `feature_*`
convention and must be tagged with `isFeature: true` in the setting definition. By doing so, the feature will automatically
appear in the "labs" section of the user's settings.
Features can be controlled at the config level using the following structure:
```json
"features": {
"feature_lazyloading": true
}
```
When `true`, the user will see the feature as enabled. Similarly, when `false` the user will see the feature as disabled.
The user will only be able to change/see these states if `show_labs_settings: true` is in the config.
### Determining if a feature is enabled
Call `SettingsStore.getValue()` as you would for any other setting.
### Enabling a feature
Call `SettingsStore.setValue("feature_name", null, SettingLevel.DEVICE, true)`.
### A note on UI features
UI features are a different concept to plain features. Instead of being representative of unstable or
unpredicatable behaviour, they are logical chunks of UI which can be disabled by deployments for ease
of understanding with users. They are simply represented as boring settings with a convention of being
named as `UIFeature.$location` where `$location` is a rough descriptor of what is toggled, such as
`URLPreviews` or `Communities`.
UI features also tend to have their own setting controller (see below) to manipulate settings which might
be affected by the UI feature being disabled. For example, if URL previews are disabled as a UI feature
then the URL preview options will use the `UIFeatureController` to ensure they remain disabled while the
UI feature is disabled.
## Setting controllers
Settings may have environmental factors that affect their value or need additional code to be called when they are
modified. A setting controller is able to override the calculated value for a setting and react to changes in that
setting. Controllers are not a replacement for the level handlers and should only be used to ensure the environment is
kept up to date with the setting where it is otherwise not possible. An example of this is the notification settings:
they can only be considered enabled if the platform supports notifications, and enabling notifications requires
additional steps to actually enable notifications.
For more information, see `src/settings/controllers/SettingController.ts`.
## Local echo
`SettingsStore` will perform local echo on all settings to ensure that immediately getting values does not cause a
split-brain scenario. As mentioned in the "Setting values for a setting" section, the appropriate checks should be done
to ensure that the user is allowed to set the value. The local echo system assumes that the user has permission and that
the request will go through successfully. The local echo only takes effect until the request to save a setting has
completed (either successfully or otherwise).
```javascript
SettingsStore.setValue(...).then(() => {
// The value has actually been stored at this point.
});
SettingsStore.getValue(...); // this will return the value set in `setValue` above.
```
## Watching for changes
Most use cases do not need to set up a watcher because they are able to react to changes as they are made, or the
changes which are made are not significant enough for it to matter. Watchers are intended to be used in scenarios where
it is important to react to changes made by other logged in devices. Typically, this would be done within the component
itself, however the component should not be aware of the intricacies of setting inversion or remapping to particular
data structures. Instead, a generic watcher interface is provided on `SettingsStore` to watch (and subsequently unwatch)
for changes in a setting.
An example of a watcher in action would be:
```javascript
class MyComponent extends React.Component {
settingWatcherRef = null;
componentDidMount() {
const callback = (settingName, roomId, level, newValAtLevel, newVal) => {
this.setState({ color: newVal });
};
this.settingWatcherRef = SettingsStore.watchSetting("roomColor", "!example:matrix.org", callback);
}
componentWillUnmount() {
SettingsStore.unwatchSetting(this.settingWatcherRef);
}
}
```
# Maintainers Reference
The granular settings system has a few complex parts to power it. This section is to document how the `SettingsStore` is
supposed to work.
### General information
The `SettingsStore` uses the hardcoded `LEVEL_ORDER` constant to ensure that it is using the correct override procedure.
The array is checked from left to right, simulating the behaviour of overriding values from the higher levels. Each
level should be defined in this array, including `default`.
Handlers (`src/settings/handlers/SettingsHandler.ts`) represent a single level and are responsible for getting and
setting values at that level. Handlers also provide additional information to the `SettingsStore` such as if the level
is supported or if the current user may set values at the level. The `SettingsStore` will use the handler to enforce
checks and manipulate settings. Handlers are also responsible for dealing with migration patterns or legacy settings for
their level (for example, a setting being renamed or using a different key from other settings in the underlying store).
Handlers are provided to the `SettingsStore` via the `LEVEL_HANDLERS` constant. `SettingsStore` will optimize lookups by
only considering handlers that are supported on the platform.
Local echo is achieved through `src/settings/handlers/LocalEchoWrapper.ts` which acts as a wrapper around a given
handler. This is automatically applied to all defined `LEVEL_HANDLERS` and proxies the calls to the wrapped handler
where possible. The echo is achieved by a simple object cache stored within the class itself. The cache is invalidated
immediately upon the proxied save call succeeding or failing.
Controllers are notified of changes by the `SettingsStore`, and are given the opportunity to override values after the
`SettingsStore` has deemed the value calculated. Controllers are invoked as the last possible step in the code.
### Features
See above for feature reference.
### Watchers
Watchers can appear complicated under the hood: there is a central `WatchManager` which handles the actual invocation
of callbacks, and callbacks are managed by the SettingsStore by redirecting the caller's callback to a dedicated
callback. This is done so that the caller can reuse the same function as their callback without worrying about whether
or not it'll unsubscribe all watchers.
Setting changes are emitted into the default `WatchManager`, which calculates the new value for the setting. Ideally,
we'd also try and suppress updates which don't have a consequence on this value, however there's not an easy way to do
this. Instead, we just dispatch an update for all changes and leave it up to the consumer to deduplicate.
In practice, handlers which rely on remote changes (account data, room events, etc) will always attach a listener to the
`MatrixClient`. They then watch for changes to events they care about and send off appropriate updates to the
generalized `WatchManager` - a class specifically designed to deduplicate the logic of managing watchers. The handlers
which are localized to the local client (device) generally just trigger the `WatchManager` when they manipulate the
setting themselves as there's nothing to really 'watch'.

18
docs/skinning.md Normal file
View file

@ -0,0 +1,18 @@
# Skinning
Skinning in the context of the react-sdk is component replacement rather than CSS. This means you can override (replace)
any accessible component in the project to implement custom behaviour, look & feel, etc. Depending on your approach,
overriding CSS classes to apply custom styling is also possible, though harder to do.
At present, the react-sdk offers no stable interface for components - this means properties and state can and do change
at any time without notice. Once we determine the react-sdk to be stable enough to use as a proper SDK, we will adjust
this policy. In the meantime, skinning is done completely at your own risk.
The approach you take is up to you - we suggest using a module replacement plugin, as found in
[webpack](https://webpack.js.org/plugins/normal-module-replacement-plugin/), though you're free to use whichever build
system works for you. The react-sdk does not have any particular functions to call to load skins, so simply replace or
extend the components/stores/etc you're after and build. As a reminder though, this is done completely at your own risk
as we cannot guarantee a stable interface at this time.
Taking a look at [element-web](https://github.com/vector-im/element-web)'s approach to skinning may be worthwhile, as it
overrides some relatively simple components.

View file

@ -3,21 +3,17 @@
Themes are a very basic way of providing simple alternative look & feels to the Themes are a very basic way of providing simple alternative look & feels to the
Element app via CSS & custom imagery. Element app via CSS & custom imagery.
They are _NOT_ co be confused with 'skins', which describe apps which sit on top
of matrix-react-sdk - e.g. in theory Element itself is a react-sdk skin.
As of March 2022, skins are not fully supported; Element is the only available skin.
To define a theme for Element: To define a theme for Element:
1. Pick a name, e.g. `teal`. at time of writing we have `light` and `dark`. 1. Pick a name, e.g. `teal`. at time of writing we have `light` and `dark`.
2. Fork `src/skins/vector/css/themes/dark.pcss` to be `teal.pcss` 2. Fork `res/themes/dark/css/dark.pcss` to be `teal.pcss`
3. Fork `src/skins/vector/css/themes/_base.pcss` to be `_teal.pcss` 3. Fork `res/themes/dark/css/_base.pcss` to be `_teal.pcss`
4. Override variables in `_teal.pcss` as desired. You may wish to delete ones 4. Override variables in `_teal.pcss` as desired. You may wish to delete ones
which don't differ from `_base.pcss`, to make it clear which are being which don't differ from `_base.pcss`, to make it clear which are being
overridden. If every single colour is being changed (as per `_dark.pcss`) overridden. If every single colour is being changed (as per `_dark.pcss`)
then you might as well keep them all. then you might as well keep them all.
5. Add the theme to the list of entrypoints in webpack.config.js 5. Add the theme to the list of entrypoints in webpack.config.js
6. Add the theme to the list of themes in matrix-react-sdk's UserSettings.js 6. Add the theme to the list of themes in theme.ts
7. Sit back and admire your handywork. 7. Sit back and admire your handywork.
In future, the assets for a theme will probably be gathered together into a In future, the assets for a theme will probably be gathered together into a

View file

@ -3,13 +3,12 @@
## Requirements ## Requirements
- A working [Development Setup](../README.md#setting-up-a-dev-environment) - A working [Development Setup](../README.md#setting-up-a-dev-environment)
- Including up-to-date versions of matrix-react-sdk and matrix-js-sdk
- Latest LTS version of Node.js installed - Latest LTS version of Node.js installed
- Be able to understand English - Be able to understand English
## Translating strings vs. marking strings for translation ## Translating strings vs. marking strings for translation
Translating strings are done with the `_t()` function found in matrix-react-sdk/lib/languageHandler.js. Translating strings are done with the `_t()` function found in `languageHandler.tsx`.
It is recommended to call this function wherever you introduce a string constant which should be translated. It is recommended to call this function wherever you introduce a string constant which should be translated.
However, translating can not be performed until after the translation system has been initialized. However, translating can not be performed until after the translation system has been initialized.
Thus, sometimes translation must be performed at a different location in the source code than where the string is introduced. Thus, sometimes translation must be performed at a different location in the source code than where the string is introduced.
@ -49,7 +48,7 @@ We are aiming for a set of common strings to be shared then some more localised
## Adding new strings ## Adding new strings
1. Check if the import `import { _t } from 'matrix-react-sdk/src/languageHandler';` is present. If not add it to the other import statements. Also import `_td` if needed. 1. Check if the import `import { _t } from ".../languageHandler";` is present. If not add it to the other import statements. Also import `_td` if needed.
1. Add `_t()` to your string passing the translation key you come up with based on the rules above. If the string is introduced at a point before the translation system has not yet been initialized, use `_td()` instead, and call `_t()` at the appropriate time. 1. Add `_t()` to your string passing the translation key you come up with based on the rules above. If the string is introduced at a point before the translation system has not yet been initialized, use `_td()` instead, and call `_t()` at the appropriate time.
1. Run `yarn i18n` to add the keys to `src/i18n/strings/en_EN.json` 1. Run `yarn i18n` to add the keys to `src/i18n/strings/en_EN.json`
1. Modify the new entries in `src/i18n/strings/en_EN.json` with the English (UK) translations for the added keys. 1. Modify the new entries in `src/i18n/strings/en_EN.json` with the English (UK) translations for the added keys.

27
docs/usercontent.md Normal file
View file

@ -0,0 +1,27 @@
# Usercontent
While decryption itself is safe to be done without a sandbox,
letting the browser and user interact with the resulting data may be dangerous,
previously `usercontent.riot.im` was used to act as a sandbox on a different origin to close the attack surface,
it is now possible to do by using a combination of a sandboxed iframe and some code written into the app which consumes this SDK.
Usercontent is an iframe sandbox target for allowing a user to safely download a decrypted attachment from a sandboxed origin where it cannot be used to XSS your Element session out from under you.
Its function is to create an Object URL for the user/browser to use but bound to an origin different to that of the Element instance to protect against XSS.
It exposes a function over a postMessage API, when sent an object with the matching fields to render a download link with the Object URL:
```json5
{
imgSrc: "", // the src of the image to display in the download link
imgStyle: "", // the style to apply to the image
style: "", // the style to apply to the download link
download: "", // download attribute to pass to the <a/> tag
textContent: "", // the text to put inside the download link
blob: "", // the data blob to wrap in an object url and allow the user to download
}
```
If only imgSrc, imgStyle and style are passed then just update the existing link without overwriting other things about it.
It is expected that this target be available at `usercontent/` relative to the root of the app, this can be seen in element-web's webpack config.

61
docs/widget-layouts.md Normal file
View file

@ -0,0 +1,61 @@
# Widget layout support
Rooms can have a default widget layout to auto-pin certain widgets, make the container different
sizes, etc. These are defined through the `io.element.widgets.layout` state event (empty state key).
Full example content:
```json5
{
widgets: {
"first-widget-id": {
container: "top",
index: 0,
width: 60,
height: 40,
},
"second-widget-id": {
container: "right",
},
},
}
```
As shown, there are two containers possible for widgets. These containers have different behaviour
and interpret the other options differently.
## `top` container
This is the "App Drawer" or any pinned widgets in a room. This is by far the most versatile container
though does introduce potential usability issues upon members of the room (widgets take up space and
therefore fewer messages can be shown).
The `index` for a widget determines which order the widgets show up in from left to right. Widgets
without an `index` will show up as the rightmost widgets. Tiebreaks (same `index` or multiple defined
without an `index`) are resolved by comparing widget IDs. A maximum of 3 widgets can be in the top
container - any which exceed this will be ignored (placed into the `right` container). Smaller numbers
represent leftmost widgets.
The `width` is relative width within the container in percentage points. This will be clamped to a
range of 0-100 (inclusive). The widgets will attempt to scale to relative proportions when more than
100% space is allocated. For example, if 3 widgets are defined at 40% width each then the client will
attempt to show them at 33% width each.
Note that the client may impose minimum widths on the widgets, such as a 10% minimum to avoid pinning
hidden widgets. In general, widgets defined in the 30-70% range each will be free of these restrictions.
The `height` is not in fact applied per-widget but is recorded per-widget for potential future
capabilities in future containers. The top container will take the tallest `height` and use that for
the height of the whole container, and thus all widgets in that container. The `height` is relative
to the container, like with `width`, meaning that 100% will consume as much space as the client is
willing to sacrifice to the widget container. Like with `width`, the client may impose minimums to avoid
the container being uselessly small. Heights in the 30-100% range are generally acceptable. The height
is also clamped to be within 0-100, inclusive.
## `right` container
This is the default container and has no special configuration. Widgets which overflow from the top
container will be put in this container instead. Putting a widget in the right container does not
automatically show it - it only mentions that widgets should not be in another container.
The behaviour of this container may change in the future.

View file

@ -16,35 +16,52 @@ const config: Config = {
url: "http://localhost/", url: "http://localhost/",
}, },
testMatch: ["<rootDir>/test/**/*-test.[tj]s?(x)"], testMatch: ["<rootDir>/test/**/*-test.[tj]s?(x)"],
setupFiles: ["jest-canvas-mock"], globalSetup: "<rootDir>/test/globalSetup.ts",
setupFilesAfterEnv: ["<rootDir>/node_modules/matrix-react-sdk/test/setupTests.ts"], setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"],
setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"],
moduleNameMapper: { moduleNameMapper: {
"\\.(css|scss|pcss)$": "<rootDir>/__mocks__/cssMock.js", "\\.(css|scss|pcss)$": "<rootDir>/__mocks__/cssMock.js",
"\\.(gif|png|ttf|woff2)$": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/imageMock.js", "\\.(gif|png|ttf|woff2)$": "<rootDir>/__mocks__/imageMock.js",
"\\.svg$": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/svg.js", "\\.svg$": "<rootDir>/__mocks__/svg.js",
"\\$webapp/i18n/languages.json": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/languages.json", "\\$webapp/i18n/languages.json": "<rootDir>/__mocks__/languages.json",
"^react$": "<rootDir>/node_modules/react", "^react$": "<rootDir>/node_modules/react",
"^react-dom$": "<rootDir>/node_modules/react-dom", "^react-dom$": "<rootDir>/node_modules/react-dom",
"^matrix-js-sdk$": "<rootDir>/node_modules/matrix-js-sdk/src", "^matrix-js-sdk$": "<rootDir>/node_modules/matrix-js-sdk/src",
"^matrix-react-sdk$": "<rootDir>/node_modules/matrix-react-sdk/src", "^matrix-react-sdk$": "<rootDir>/src",
"decoderWorker\\.min\\.js": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/empty.js", "decoderWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js",
"decoderWorker\\.min\\.wasm": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/empty.js", "decoderWorker\\.min\\.wasm": "<rootDir>/__mocks__/empty.js",
"waveWorker\\.min\\.js": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/empty.js", "waveWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js",
"context-filter-polyfill": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/empty.js", "context-filter-polyfill": "<rootDir>/__mocks__/empty.js",
"FontManager.ts": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/FontManager.js", "FontManager.ts": "<rootDir>/__mocks__/FontManager.js",
"workers/(.+)Factory": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/workerFactoryMock.js", "workers/(.+)Factory": "<rootDir>/__mocks__/workerFactoryMock.js",
"^!!raw-loader!.*": "jest-raw-loader", "^!!raw-loader!.*": "jest-raw-loader",
"recorderWorkletFactory": "<rootDir>/node_modules/matrix-react-sdk/__mocks__/empty.js", "recorderWorkletFactory": "<rootDir>/__mocks__/empty.js",
"^fetch-mock$": "<rootDir>/node_modules/fetch-mock", "^fetch-mock$": "<rootDir>/node_modules/fetch-mock",
}, },
transformIgnorePatterns: ["/node_modules/(?!matrix-js-sdk).+$", "/node_modules/(?!matrix-react-sdk).+$"], transformIgnorePatterns: ["/node_modules/(?!matrix-js-sdk).+$"],
collectCoverageFrom: [
"<rootDir>/src/**/*.{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",
// Coverage chokes on type definition files
"!<rootDir>/src/**/*.d.ts",
],
coverageReporters: ["text-summary", "lcov"], coverageReporters: ["text-summary", "lcov"],
testResultsProcessor: "@casualbot/jest-sonar-reporter", testResultsProcessor: "@casualbot/jest-sonar-reporter",
prettierPath: null,
moduleDirectories: ["node_modules", "test/test-utils"],
}; };
// if we're running under GHA, enable the GHA reporter // if we're running under GHA, enable the GHA reporter
if (env["GITHUB_ACTIONS"] !== undefined) { if (env["GITHUB_ACTIONS"] !== undefined) {
config.reporters = [["github-actions", { silent: false }], "summary"]; 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; export default config;

View file

@ -32,8 +32,10 @@
"scripts": { "scripts": {
"i18n": "matrix-gen-i18n && 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: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": "prettier --log-level=silent --write src/i18n/strings/ --ignore-path /dev/null", "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", "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",
"make-component": "node scripts/make-react-component.js",
"rethemendex": "res/css/rethemendex.sh",
"clean": "rimraf lib webapp", "clean": "rimraf lib webapp",
"build": "yarn clean && yarn build:genfiles && yarn build:bundle", "build": "yarn clean && yarn build:genfiles && yarn build:bundle",
"build-stats": "yarn clean && yarn build:genfiles && yarn build:bundle-stats", "build-stats": "yarn clean && yarn build:genfiles && yarn build:bundle-stats",
@ -50,51 +52,114 @@
"start:js": "webpack serve --output-path webapp --output-filename=bundles/_dev_/[name].js --output-chunk-filename=bundles/_dev_/[name].js --mode development", "start:js": "webpack serve --output-path webapp --output-filename=bundles/_dev_/[name].js --output-chunk-filename=bundles/_dev_/[name].js --mode development",
"lint": "yarn lint:types && yarn lint:js && yarn lint:style && yarn lint:workflows", "lint": "yarn lint:types && yarn lint:js && yarn lint:style && yarn lint:workflows",
"lint:js": "yarn lint:js:src && yarn lint:js:module_system", "lint:js": "yarn lint:js:src && yarn lint:js:module_system",
"lint:js:src": "eslint --max-warnings 0 src test && prettier --check .", "lint:js:src": "eslint --max-warnings 0 src test playwright && prettier --check .",
"lint:js:module_system": "eslint --max-warnings 0 --config .eslintrc-module_system.js module_system", "lint:js:module_system": "eslint --max-warnings 0 --config .eslintrc-module_system.js module_system",
"lint:js-fix": "yarn lint:js-fix:src && yarn lint:js-fix:module_system", "lint:js-fix": "yarn lint:js-fix:src && yarn lint:js-fix:module_system",
"lint:js-fix:src": "prettier --log-level=warn --write . && eslint --fix src test", "lint:js-fix:src": "prettier --log-level=warn --write . && eslint --fix src test playwright",
"lint:js-fix:module_system": "eslint --fix --config .eslintrc-module_system.js module_system", "lint:js-fix:module_system": "eslint --fix --config .eslintrc-module_system.js module_system",
"lint:types": "yarn lint:types:src && yarn lint:types:module_system", "lint:types": "yarn lint:types:src && yarn lint:types:module_system",
"lint:types:src": "tsc --noEmit --jsx react", "lint:types:src": "tsc --noEmit --jsx react && tsc --noEmit --jsx react -p playwright",
"lint:types:module_system": "tsc --noEmit --project ./tsconfig.module_system.json", "lint:types:module_system": "tsc --noEmit --project ./tsconfig.module_system.json",
"lint:style": "stylelint \"res/css/**/*.pcss\"", "lint:style": "stylelint \"res/css/**/*.pcss\"",
"lint:workflows": "find .github/workflows -type f \\( -iname '*.yaml' -o -iname '*.yml' \\) | xargs -I {} sh -c 'echo \"Linting {}\"; action-validator \"{}\"'", "lint:workflows": "find .github/workflows -type f \\( -iname '*.yaml' -o -iname '*.yml' \\) | xargs -I {} sh -c 'echo \"Linting {}\"; action-validator \"{}\"'",
"test": "jest", "test": "jest",
"test:playwright": "playwright test",
"test:playwright:open": "yarn test:playwright --ui",
"test:playwright:screenshots": "yarn test:playwright:screenshots:build && yarn test:playwright:screenshots:run",
"test:playwright:screenshots:build": "docker build playwright -t element-web-playwright",
"test:playwright:screenshots:run": "docker run --rm --network host -e BASE_URL -e CI -v $(pwd):/work/ -v $(node -e 'console.log(require(`path`).dirname(require.resolve(`matrix-js-sdk/package.json`)))'):/work/node_modules/matrix-js-sdk -v /var/run/docker.sock:/var/run/docker.sock -v /tmp/:/tmp/ -it element-web-playwright",
"coverage": "yarn test --coverage", "coverage": "yarn test --coverage",
"analyse:unused-exports": "ts-node ./scripts/analyse_unused_exports.ts", "analyse:unused-exports": "ts-node ./scripts/analyse_unused_exports.ts",
"analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp", "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" "update:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js"
}, },
"resolutions": { "resolutions": {
"@types/react-dom": "17.0.25", "@types/seedrandom": "3.0.8",
"@types/react": "17.0.82", "oidc-client-ts": "3.1.0",
"@vector-im/compound-design-tokens": "1.8.0", "jwt-decode": "4.0.0",
"@vector-im/compound-web": "7.0.0", "caniuse-lite": "1.0.30001668",
"@floating-ui/react": "0.26.11", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
"@radix-ui/react-id": "1.1.0" "wrap-ansi": "npm:wrap-ansi@^7.0.0"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5",
"@formatjs/intl-segmenter": "^11.5.7", "@formatjs/intl-segmenter": "^11.5.7",
"@matrix-org/react-sdk-module-api": "^2.3.0", "@matrix-org/analytics-events": "^0.27.0",
"@matrix-org/emojibase-bindings": "^1.3.3",
"@vector-im/matrix-wysiwyg": "2.37.13",
"@matrix-org/react-sdk-module-api": "^2.4.0",
"@matrix-org/spec": "^1.7.0",
"@sentry/browser": "^8.0.0",
"@vector-im/compound-design-tokens": "^1.8.0", "@vector-im/compound-design-tokens": "^1.8.0",
"@vector-im/compound-web": "^7.0.0", "@vector-im/compound-web": "^7.1.0",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
"await-lock": "^2.1.0",
"bloom-filters": "^3.0.1",
"blurhash": "^2.0.3",
"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",
"emojibase-regex": "15.3.2",
"escape-html": "^1.0.3",
"file-saver": "^2.0.5",
"filesize": "10.1.6",
"github-markdown-css": "^5.5.1",
"glob-to-regexp": "^0.4.1",
"highlight.js": "^11.3.1",
"html-entities": "^2.0.0",
"is-ip": "^3.1.0",
"jsrsasign": "^11.0.0", "jsrsasign": "^11.0.0",
"js-xxhash": "^4.0.0",
"jszip": "^3.7.0",
"katex": "^0.16.0", "katex": "^0.16.0",
"linkify-element": "4.1.3",
"linkify-react": "4.1.3",
"linkify-string": "4.1.3",
"linkifyjs": "4.1.3",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"matrix-js-sdk": "34.9.0", "maplibre-gl": "^2.0.0",
"matrix-react-sdk": "3.114.0", "matrix-encrypt-attachment": "^1.0.3",
"matrix-widget-api": "^1.8.2", "matrix-events-sdk": "0.0.1",
"react": "17.0.2", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
"react-dom": "17.0.2", "matrix-widget-api": "^1.9.0",
"ua-parser-js": "^1.0.0" "memoize-one": "^6.0.0",
"oidc-client-ts": "^3.0.1",
"opus-recorder": "^8.0.3",
"pako": "^2.0.3",
"png-chunks-extract": "^1.0.0",
"posthog-js": "1.157.2",
"qrcode": "1.5.4",
"re-resizable": "6.9.17",
"react": "^18.3.1",
"react-beautiful-dnd": "^13.1.0",
"react-blurhash": "^0.3.0",
"react-dom": "^18.3.1",
"react-focus-lock": "^2.5.1",
"react-transition-group": "^4.4.1",
"rfc4648": "^1.4.0",
"sanitize-filename": "^1.6.3",
"sanitize-html": "2.13.1",
"tar-js": "^0.3.0",
"temporal-polyfill": "^0.2.5",
"ua-parser-js": "^1.0.2",
"uuid": "^10.0.0",
"what-input": "^5.2.10"
}, },
"devDependencies": { "devDependencies": {
"@action-validator/cli": "^0.6.0", "@action-validator/cli": "^0.6.0",
"@action-validator/core": "^0.6.0", "@action-validator/core": "^0.6.0",
"@axe-core/playwright": "^4.8.1",
"@babel/cli": "^7.12.10",
"@babel/core": "^7.12.10", "@babel/core": "^7.12.10",
"@babel/eslint-parser": "^7.12.10", "@babel/eslint-parser": "^7.12.10",
"@babel/eslint-plugin": "^7.12.10", "@babel/eslint-plugin": "^7.12.10",
"@babel/parser": "^7.12.11",
"@babel/plugin-proposal-export-default-from": "^7.12.1", "@babel/plugin-proposal-export-default-from": "^7.12.1",
"@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-class-properties": "^7.12.1", "@babel/plugin-transform-class-properties": "^7.12.1",
@ -109,48 +174,61 @@
"@babel/preset-typescript": "^7.12.7", "@babel/preset-typescript": "^7.12.7",
"@babel/register": "^7.12.10", "@babel/register": "^7.12.10",
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@casualbot/jest-sonar-reporter": "2.3.1", "@casualbot/jest-sonar-reporter": "2.2.7",
"@peculiar/webcrypto": "^1.4.3",
"@playwright/test": "^1.40.1",
"@principalstudio/html-webpack-inject-preload": "^1.2.7", "@principalstudio/html-webpack-inject-preload": "^1.2.7",
"@sentry/webpack-plugin": "^2.7.1", "@sentry/webpack-plugin": "^2.7.1",
"@stylistic/eslint-plugin": "^2.9.0",
"@svgr/webpack": "^8.0.0", "@svgr/webpack": "^8.0.0",
"@testing-library/react": "^12.1.5", "@testing-library/dom": "^10.4.0",
"@types/commonmark": "^0.27.9", "@testing-library/jest-dom": "^6.4.8",
"@types/content-type": "^1.1.8", "@testing-library/react": "^16.0.0",
"@types/counterpart": "^0.18.4", "@testing-library/user-event": "^14.5.2",
"@types/diff-match-patch": "^1.0.36", "@types/commonmark": "^0.27.4",
"@types/escape-html": "^1.0.4", "@types/content-type": "^1.1.5",
"@types/file-saver": "^2.0.7", "@types/counterpart": "^0.18.1",
"@types/glob-to-regexp": "^0.4.4", "@types/css-tree": "^2.3.8",
"@types/jest": "^29.0.0", "@types/diff-match-patch": "^1.0.32",
"@types/escape-html": "^1.0.1",
"@types/express": "^5.0.0",
"@types/file-saver": "^2.0.3",
"@types/fs-extra": "^11.0.0",
"@types/glob-to-regexp": "^0.4.1",
"@types/jest": "29.5.12",
"@types/jitsi-meet": "^2.0.2", "@types/jitsi-meet": "^2.0.2",
"@types/jsrsasign": "^10.5.4", "@types/jsrsasign": "^10.5.4",
"@types/katex": "^0.16.7", "@types/katex": "^0.16.0",
"@types/lodash": "^4.14.197", "@types/lodash": "^4.14.168",
"@types/minimist": "^1.2.5", "@types/minimist": "^1.2.5",
"@types/modernizr": "^3.5.6", "@types/modernizr": "^3.5.3",
"@types/node": "^16", "@types/node": "18",
"@types/node-fetch": "^2.6.4", "@types/node-fetch": "^2.6.2",
"@types/pako": "^2.0.3", "@types/pako": "^2.0.0",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.3.5",
"@types/react": "17.0.82", "@types/react": "18.3.3",
"@types/react-beautiful-dnd": "^13.1.7", "@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "17.0.25", "@types/react-dom": "18.3.0",
"@types/react-transition-group": "^4.4.9", "@types/react-transition-group": "^4.4.0",
"@types/sanitize-html": "^2.9.5", "@types/sanitize-html": "2.13.0",
"@types/sdp-transform": "^2.4.9", "@types/sdp-transform": "^2.4.6",
"@types/seedrandom": "3.0.8",
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
"@types/tar-js": "^0.3.5", "@types/tar-js": "^0.3.5",
"@types/ua-parser-js": "^0.7.36", "@types/ua-parser-js": "^0.7.36",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^7.0.0", "@typescript-eslint/parser": "^8.0.0",
"axe-core": "4.10.0",
"babel-jest": "^29.0.0", "babel-jest": "^29.0.0",
"babel-loader": "^9.0.0", "babel-loader": "^9.0.0",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0", "babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"blob-polyfill": "^9.0.0",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"concurrently": "^9.0.0", "concurrently": "^9.0.0",
"copy-webpack-plugin": "^12.0.0", "copy-webpack-plugin": "^12.0.0",
"core-js": "^3.38.1",
"cronstrue": "^2.41.0", "cronstrue": "^2.41.0",
"css-loader": "^7.0.0", "css-loader": "^7.0.0",
"css-minimizer-webpack-plugin": "^7.0.0", "css-minimizer-webpack-plugin": "^7.0.0",
@ -159,30 +237,39 @@
"eslint-config-google": "^0.14.0", "eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.0.0",
"eslint-plugin-deprecate": "0.8.5", "eslint-plugin-deprecate": "0.8.5",
"eslint-plugin-import": "^2.26.0", "eslint-plugin-import": "^2.25.4",
"eslint-plugin-matrix-org": "^1.0.0", "eslint-plugin-jest": "^28.0.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-matrix-org": "^2.0.2",
"eslint-plugin-react": "^7.28.0", "eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-hooks": "^4.3.0", "eslint-plugin-react-hooks": "^4.3.0",
"eslint-plugin-unicorn": "^55.0.0", "eslint-plugin-unicorn": "^56.0.0",
"express": "^4.18.2",
"fake-indexeddb": "^6.0.0", "fake-indexeddb": "^6.0.0",
"fetch-mock": "9.11.0", "fetch-mock": "9.11.0",
"fetch-mock-jest": "^1.5.1", "fetch-mock-jest": "^1.5.1",
"file-loader": "^6.0.0", "file-loader": "^6.0.0",
"fs-extra": "^11.0.0",
"glob": "^11.0.0",
"html-webpack-plugin": "^5.5.3", "html-webpack-plugin": "^5.5.3",
"husky": "^9.0.0", "husky": "^9.0.0",
"jest": "^29.0.0", "jest": "^29.6.2",
"jest-canvas-mock": "2.5.2", "jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^29.0.0", "jest-environment-jsdom": "^29.7.0",
"jest-mock": "^29.0.0", "jest-mock": "^29.6.2",
"jest-raw-loader": "^1.0.1", "jest-raw-loader": "^1.0.1",
"lint-staged": "^15.1.0", "jsqr": "^1.4.0",
"lint-staged": "^15.0.2",
"mailhog": "^4.16.0",
"matrix-mock-request": "^2.5.0", "matrix-mock-request": "^2.5.0",
"matrix-web-i18n": "^3.2.1", "matrix-web-i18n": "^3.2.1",
"mini-css-extract-plugin": "2.9.0", "mini-css-extract-plugin": "2.9.0",
"minimist": "^1.2.6", "minimist": "^1.2.6",
"mkdirp": "^3.0.0", "mkdirp": "^3.0.0",
"mocha-junit-reporter": "^2.2.0",
"modernizr": "^3.12.0", "modernizr": "^3.12.0",
"node-fetch": "^2.6.7", "node-fetch": "^2.6.7",
"playwright-core": "^1.45.1",
"postcss": "8.4.38", "postcss": "8.4.38",
"postcss-easings": "^4.0.0", "postcss-easings": "^4.0.0",
"postcss-hexrgba": "2.1.0", "postcss-hexrgba": "2.1.0",
@ -204,8 +291,9 @@
"terser-webpack-plugin": "^5.3.9", "terser-webpack-plugin": "^5.3.9",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"ts-prune": "^0.10.3", "ts-prune": "^0.10.3",
"typescript": "5.6.2", "typescript": "5.6.3",
"util": "^0.12.5", "util": "^0.12.5",
"web-streams-polyfill": "^4.0.0",
"webpack": "^5.89.0", "webpack": "^5.89.0",
"webpack-bundle-analyzer": "^4.8.0", "webpack-bundle-analyzer": "^4.8.0",
"webpack-cli": "^5.0.0", "webpack-cli": "^5.0.0",

39
playwright.config.ts Normal file
View file

@ -0,0 +1,39 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { defineConfig } from "@playwright/test";
const baseURL = process.env["BASE_URL"] ?? "http://localhost:8080";
export default defineConfig({
use: {
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: true,
video: "retain-on-failure",
baseURL,
permissions: ["clipboard-write", "clipboard-read", "microphone"],
launchOptions: {
args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream", "--mute-audio"],
},
trace: "on-first-retry",
},
webServer: {
command: process.env.CI ? "npx serve -p 8080 -L ./webapp" : "yarn start",
url: `${baseURL}/config.json`,
reuseExistingServer: true,
timeout: (process.env.CI ? 30 : 120) * 1000,
},
testDir: "playwright/e2e",
outputDir: "playwright/test-results",
workers: 1,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? [["blob"], ["github"]] : [["html", { outputFolder: "playwright/html-report" }]],
snapshotDir: "playwright/snapshots",
snapshotPathTemplate: "{snapshotDir}/{testFilePath}/{arg}-{platform}{ext}",
forbidOnly: !!process.env.CI,
});

8
playwright/.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
/test-results/
/html-report/
/logs/
# Only commit snapshots from Linux
/snapshots/**/*.png
!/snapshots/**/*-linux.png
# This file is machine-generated
/e2e/crypto/test_indexeddb_cryptostore_dump/dump.json

12
playwright/@types/playwright-core.d.ts vendored Normal file
View file

@ -0,0 +1,12 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
declare module "playwright-core/lib/utils" {
// This type is not public in playwright-core utils
export function sanitizeForFilePath(filePath: string): string;
}

9
playwright/Dockerfile Normal file
View file

@ -0,0 +1,9 @@
FROM mcr.microsoft.com/playwright:v1.48.0-jammy
WORKDIR /work
# fonts-dejavu is needed for the same RTL rendering as on CI
RUN apt-get update && apt-get -y install docker.io fonts-dejavu
COPY docker-entrypoint.sh /opt/docker-entrypoint.sh
ENTRYPOINT ["bash", "/opt/docker-entrypoint.sh"]

View file

@ -0,0 +1,5 @@
#!/bin/bash
set -e
npx playwright test --update-snapshots --reporter line $@

View file

@ -0,0 +1,161 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
import { Bot } from "../../pages/bot";
test.describe("Landmark navigation tests", () => {
test.use({
displayName: "Alice",
});
test("without any rooms", async ({ page, homeserver, app, user }) => {
// sometimes the space button doesn't appear right away
await expect(page.locator(".mx_SpaceButton_active")).toBeVisible();
/**
* Without any rooms, there is no tile in the roomlist to be focused.
* So the next landmark in the list should be focused instead.
*/
// Pressing Control+F6 will first focus the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
// Pressing Control+F6 again will focus room search
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
// Pressing Control+F6 again will focus the message composer
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_HomePage")).toBeFocused();
// Pressing Control+F6 again will bring focus back to the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
// Now go back in the same order
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_HomePage")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
});
test("with an open room", async ({ page, homeserver, app, user }) => {
const bob = new Bot(page, homeserver, { displayName: "Bob" });
await bob.prepareClient();
// create dm with bob
await app.client.evaluate(
async (cli, { bob }) => {
const bobRoom = await cli.createRoom({ is_direct: true });
await cli.invite(bobRoom.room_id, bob);
},
{
bob: bob.credentials.userId,
},
);
await app.viewRoomByName("Bob");
// confirm the room was loaded
await expect(page.getByText("Bob joined the room")).toBeVisible();
// Pressing Control+F6 will first focus the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
// Pressing Control+F6 again will focus room search
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
// Pressing Control+F6 again will focus the room tile in the room list
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomTile_selected")).toBeFocused();
// Pressing Control+F6 again will focus the message composer
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_BasicMessageComposer_input")).toBeFocused();
// Pressing Control+F6 again will bring focus back to the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
// Now go back in the same order
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_BasicMessageComposer_input")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomTile_selected")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
});
test("without an open room", async ({ page, homeserver, app, user }) => {
const bob = new Bot(page, homeserver, { displayName: "Bob" });
await bob.prepareClient();
// create a dm with bob
await app.client.evaluate(
async (cli, { bob }) => {
const bobRoom = await cli.createRoom({ is_direct: true });
await cli.invite(bobRoom.room_id, bob);
},
{
bob: bob.credentials.userId,
},
);
await app.viewRoomByName("Bob");
// confirm the room was loaded
await expect(page.getByText("Bob joined the room")).toBeVisible();
// Close the room
page.goto("/#/home");
// Pressing Control+F6 will first focus the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
// Pressing Control+F6 again will focus room search
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
// Pressing Control+F6 again will focus the room tile in the room list
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomTile")).toBeFocused();
// Pressing Control+F6 again will focus the home section
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_HomePage")).toBeFocused();
// Pressing Control+F6 will bring focus back to the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
// Now go back in same order
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_HomePage")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomTile")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
});
});

View file

@ -0,0 +1,34 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test(`shows error page if browser lacks Intl support`, async ({ page }) => {
await page.addInitScript({ content: `delete window.Intl;` });
await page.goto("/");
// Lack of Intl support causes the app bundle to fail to load, so we get the iframed
// static error page and need to explicitly look in the iframe because Playwright doesn't
// recurse into iframes when looking for elements
const header = page.frameLocator("iframe").getByText("Unsupported browser");
await expect(header).toBeVisible();
await expect(page).toMatchScreenshot("unsupported-browser.png");
});
test(`shows error page if browser lacks WebAssembly support`, async ({ page }) => {
await page.addInitScript({ content: `delete window.WebAssembly;` });
await page.goto("/");
// Lack of WebAssembly support doesn't cause the bundle to fail loading, so we get
// CompatibilityView, i.e. no iframes.
const header = page.getByText("Element does not support this browser");
await expect(header).toBeVisible();
await expect(page).toMatchScreenshot("unsupported-browser-CompatibilityView.png");
});

View file

@ -0,0 +1,37 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/*
* Tests for application startup with guest registration enabled on the server.
*/
import { expect, test } from "../../element-web-test";
test.use({
startHomeserverOpts: "guest-enabled",
config: async ({ homeserver }, use) => {
await use({
default_server_config: {
"m.homeserver": { base_url: homeserver.config.baseUrl },
},
});
},
});
test("Shows the welcome page by default", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Welcome to Element!" })).toBeVisible();
await expect(page.getByRole("link", { name: "Sign in" })).toBeVisible();
});
test("Room link correctly loads a room view", async ({ page }) => {
await page.goto("/#/room/!room:id");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
await expect(page).toHaveURL(/\/#\/room\/!room:id$/);
await expect(page.getByRole("heading", { name: "Join the conversation with an account" })).toBeVisible();
});

View file

@ -0,0 +1,60 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { expect, test } from "../../element-web-test";
import { ElementAppPage } from "../../pages/ElementAppPage";
/*
* Tests for application startup with credentials stored in localstorage.
*/
test.use({ displayName: "Boris" });
test("Shows the homepage by default", async ({ pageWithCredentials: page }) => {
await page.goto("/");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
await expect(page).toHaveURL(/\/#\/home/);
await expect(page.getByRole("heading", { name: "Welcome Boris", exact: true })).toBeVisible();
});
test("Shows the last known page on reload", async ({ pageWithCredentials: page }) => {
await page.goto("/");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
const app = new ElementAppPage(page);
await app.client.createRoom({ name: "Test Room" });
await app.viewRoomByName("Test Room");
// Navigate away
await page.goto("about:blank");
// And back again
await page.goto("/");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
// Check that the room reloaded
await expect(page).toHaveURL(/\/#\/room\//);
await expect(page.locator(".mx_RoomHeader")).toContainText("Test Room");
});
test("Room link correctly loads a room view", async ({ pageWithCredentials: page }) => {
await page.goto("/#/room/!room:id");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
await expect(page).toHaveURL(/\/#\/room\/!room:id$/);
await expect(page.getByRole("button", { name: "Join the discussion" })).toBeVisible();
});
test("Login link redirects to home page", async ({ pageWithCredentials: page }) => {
await page.goto("/#/login");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
await expect(page).toHaveURL(/\/#\/home/);
await expect(page.getByRole("heading", { name: "Welcome Boris", exact: true })).toBeVisible();
});

View file

@ -0,0 +1,348 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Suguru Hirahara
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import type { Locator, Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Layout } from "../../../src/settings/enums/Layout";
import { ElementAppPage } from "../../pages/ElementAppPage";
test.describe("Audio player", () => {
test.use({
displayName: "Hanako",
});
const uploadFile = async (page: Page, file: string) => {
// Upload a file from the message composer
await page.locator(".mx_MessageComposer_actions input[type='file']").setInputFiles(file);
// Find and click primary "Upload" button
await page.locator(".mx_Dialog").getByRole("button", { name: "Upload" }).click();
// Wait until the file is sent
await expect(page.locator(".mx_RoomView_statusArea_expanded")).not.toBeVisible();
await expect(page.locator(".mx_EventTile.mx_EventTile_last .mx_EventTile_receiptSent")).toBeVisible();
// wait for the tile to finish loading
await expect(
page
.locator(".mx_AudioPlayer_mediaName")
.last()
.filter({ hasText: file.split("/").at(-1) }),
).toBeVisible();
};
/**
* Take snapshots of mx_EventTile_last on each layout, outputting log for reference/debugging.
* @param detail The snapshot name. Used for outputting logs too.
* @param monospace This changes the font used to render the UI from a default one to Inconsolata. Set to false by default.
*/
const takeSnapshots = async (page: Page, app: ElementAppPage, detail: string, monospace = false) => {
// Check that the audio player is rendered and its button becomes visible
const checkPlayerVisibility = async (locator: Locator) => {
// Assert that the audio player and media information are visible
const mediaInfo = locator.locator(
".mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container .mx_AudioPlayer_mediaInfo",
);
await expect(mediaInfo.locator(".mx_AudioPlayer_mediaName", { hasText: ".ogg" })).toBeVisible(); // extension
await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "00:01" })).toBeVisible();
await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "(3.56 KB)" })).toBeVisible(); // actual size
// Assert that the play button can be found and is visible
await expect(locator.getByRole("button", { name: "Play" })).toBeVisible();
if (monospace) {
// Assert that the monospace timer is visible
await expect(locator.locator("[role='timer']")).toHaveCSS("font-family", "Inconsolata");
}
};
if (monospace) {
// Enable system font and monospace setting
await app.settings.setValue("useBundledEmojiFont", null, SettingLevel.DEVICE, false);
await app.settings.setValue("useSystemFont", null, SettingLevel.DEVICE, true);
await app.settings.setValue("systemFont", null, SettingLevel.DEVICE, "Inconsolata");
}
// Check the status of the seek bar
expect(await page.locator(".mx_AudioPlayer_seek input[type='range']").count()).toBeGreaterThan(0);
// Enable IRC layout
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
const ircTile = page.locator(".mx_EventTile_last[data-layout='irc']");
// Click the event timestamp to highlight EventTile in case it is not visible
await ircTile.locator(".mx_MessageTimestamp").click();
// Assert that rendering of the player settled and the play button is visible before taking a snapshot
await checkPlayerVisibility(ircTile);
const screenshotOptions = {
css: `
/* The timestamp is of inconsistent width depending on the time the test runs at */
.mx_MessageTimestamp {
display: none !important;
}
/* The MAB showing up on hover is not needed for the test */
.mx_MessageActionBar {
display: none !important;
}
`,
mask: [page.locator(".mx_AudioPlayer_seek")],
};
// Take a snapshot of mx_EventTile_last on IRC layout
await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot(
`${detail.replaceAll(" ", "-")}-irc-layout.png`,
screenshotOptions,
);
// Take a snapshot on modern/group layout
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group);
const groupTile = page.locator(".mx_EventTile_last[data-layout='group']");
await groupTile.locator(".mx_MessageTimestamp").click();
await checkPlayerVisibility(groupTile);
await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot(
`${detail.replaceAll(" ", "-")}-group-layout.png`,
screenshotOptions,
);
// Take a snapshot on bubble layout
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble);
const bubbleTile = page.locator(".mx_EventTile_last[data-layout='bubble']");
await bubbleTile.locator(".mx_MessageTimestamp").click();
await checkPlayerVisibility(bubbleTile);
await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot(
`${detail.replaceAll(" ", "-")}-bubble-layout.png`,
screenshotOptions,
);
};
test.beforeEach(async ({ page, app, user }) => {
await app.client.createRoom({ name: "Test Room" });
await app.viewRoomByName("Test Room");
// Wait until configuration is finished
await expect(
page
.locator(".mx_GenericEventListSummary[data-layout='group'] .mx_GenericEventListSummary_summary")
.getByText(`${user.displayName} created and configured the room.`),
).toBeVisible();
});
test("should be correctly rendered - light theme", async ({ page, app }) => {
await uploadFile(page, "playwright/sample-files/1sec-long-name-audio-file.ogg");
await takeSnapshots(page, app, "Selected EventTile of audio player (light theme)");
});
test("should be correctly rendered - light theme with monospace font", async ({ page, app }) => {
await uploadFile(page, "playwright/sample-files/1sec-long-name-audio-file.ogg");
await takeSnapshots(page, app, "Selected EventTile of audio player (light theme, monospace font)", true); // Enable monospace
});
test("should be correctly rendered - high contrast theme", async ({ page, app }) => {
// Disable system theme in case ThemeWatcher enables the theme automatically,
// so that the high contrast theme can be enabled
await app.settings.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
// Enable high contrast manually
const settings = await app.settings.openUserSettings("Appearance");
await settings.getByRole("radio", { name: "High contrast" }).click();
await app.closeDialog();
await uploadFile(page, "playwright/sample-files/1sec-long-name-audio-file.ogg");
await takeSnapshots(page, app, "Selected EventTile of audio player (high contrast)");
});
test("should be correctly rendered - dark theme", async ({ page, app }) => {
// Enable dark theme
await app.settings.setValue("theme", null, SettingLevel.ACCOUNT, "dark");
await uploadFile(page, "playwright/sample-files/1sec-long-name-audio-file.ogg");
await takeSnapshots(page, app, "Selected EventTile of audio player (dark theme)");
});
test("should play an audio file", async ({ page, app }) => {
await uploadFile(page, "playwright/sample-files/1sec.ogg");
// Assert that the audio player is rendered
const container = page.locator(".mx_EventTile_last .mx_AudioPlayer_container");
// Assert that the counter is zero before clicking the play button
await expect(container.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible();
// Find and click "Play" button, the wait is to make the test less flaky
await expect(container.getByRole("button", { name: "Play" })).toBeVisible();
await container.getByRole("button", { name: "Play" }).click();
// Assert that "Pause" button can be found
await expect(container.getByRole("button", { name: "Pause" })).toBeVisible();
// Assert that the timer is reset when the audio file finished playing
await expect(container.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible();
// Assert that "Play" button can be found
await expect(container.getByRole("button", { name: "Play" })).toBeVisible();
});
test("should support downloading an audio file", async ({ page, app }) => {
await uploadFile(page, "playwright/sample-files/1sec.ogg");
const downloadPromise = page.waitForEvent("download");
// Find and click "Download" button on MessageActionBar
const tile = page.locator(".mx_EventTile_last");
await tile.hover();
await tile.getByRole("button", { name: "Download" }).click();
// Assert that the file was downloaded
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe("1sec.ogg");
});
test("should support replying to audio file with another audio file", async ({ page, app }) => {
await uploadFile(page, "playwright/sample-files/1sec.ogg");
// Assert the audio player is rendered
await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible();
// Find and click "Reply" button on MessageActionBar
const tile = page.locator(".mx_EventTile_last");
await tile.hover();
await tile.getByRole("button", { name: "Reply", exact: true }).click();
// Reply to the player with another audio file
await uploadFile(page, "playwright/sample-files/1sec.ogg");
// Assert that the audio player is rendered
await expect(tile.locator(".mx_AudioPlayer_container")).toBeVisible();
// Assert that replied audio file is rendered as file button inside ReplyChain
const button = tile.locator(".mx_ReplyChain_wrapper .mx_MFileBody_info[role='button']");
// Assert that the file button has file name
await expect(button.locator(".mx_MFileBody_info_filename")).toBeVisible();
await takeSnapshots(page, app, "Selected EventTile of audio player with a reply");
});
test("should support creating a reply chain with multiple audio files", async ({ page, app, user }) => {
// Note: "mx_ReplyChain" element is used not only for replies which
// create a reply chain, but also for a single reply without a replied
// message. This test checks whether a reply chain which consists of
// multiple audio file replies is rendered properly.
const tile = page.locator(".mx_EventTile_last");
// Find and click "Reply" button
const clickButtonReply = async () => {
await tile.scrollIntoViewIfNeeded();
await tile.hover();
await tile.getByRole("button", { name: "Reply", exact: true }).click();
};
await uploadFile(page, "playwright/sample-files/upload-first.ogg");
// Assert that the audio player is rendered
await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible();
await clickButtonReply();
// Reply to the player with another audio file
await uploadFile(page, "playwright/sample-files/upload-second.ogg");
// Assert that the audio player is rendered
await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible();
await clickButtonReply();
// Reply to the player with yet another audio file to create a reply chain
await uploadFile(page, "playwright/sample-files/upload-third.ogg");
// Assert that the audio player is rendered
await expect(tile.locator(".mx_AudioPlayer_container")).toBeVisible();
// Assert that there are two "mx_ReplyChain" elements
await expect(tile.locator(".mx_ReplyChain")).toHaveCount(2);
// Assert that one line contains the user name
await expect(tile.locator(".mx_ReplyChain .mx_ReplyTile_sender").getByText(user.displayName)).toBeVisible();
// Assert that the other line contains the file button
await expect(tile.locator(".mx_ReplyChain .mx_MFileBody")).toBeVisible();
// Click "In reply to"
await tile.locator(".mx_ReplyChain .mx_ReplyChain_show", { hasText: "In reply to" }).click();
const replyChain = tile.locator(".mx_ReplyChain:first-of-type");
// Assert that "In reply to" has disappeared
await expect(replyChain.getByText("In reply to")).not.toBeVisible();
// Assert that the file button contains the name of the file sent at first
await expect(
replyChain
.locator(".mx_MFileBody_info[role='button']")
.locator(".mx_MFileBody_info_filename", { hasText: "upload-first.ogg" }),
).toBeVisible();
// Take snapshots
await takeSnapshots(page, app, "Selected EventTile of audio player with a reply chain");
});
test("should be rendered, play, and support replying on a thread", async ({ page, app }) => {
await uploadFile(page, "playwright/sample-files/1sec-long-name-audio-file.ogg");
// On the main timeline
const messageList = page.locator(".mx_RoomView_MessageList");
// Assert the audio player is rendered
await expect(messageList.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible();
// Find and click "Reply in thread" button
await messageList.locator(".mx_EventTile_last").hover();
await messageList.locator(".mx_EventTile_last").getByRole("button", { name: "Reply in thread" }).click();
// On a thread
const thread = page.locator(".mx_ThreadView");
const threadTile = thread.locator(".mx_EventTile_last");
const audioPlayer = threadTile.locator(".mx_AudioPlayer_container");
// Assert that the counter is zero before clicking the play button
await expect(audioPlayer.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible();
// Find and click "Play" button, the wait is to make the test less flaky
await expect(audioPlayer.getByRole("button", { name: "Play" })).toBeVisible();
await audioPlayer.getByRole("button", { name: "Play" }).click();
// Assert that "Pause" button can be found
await expect(audioPlayer.getByRole("button", { name: "Pause" })).toBeVisible();
// Assert that the timer is reset when the audio file finished playing
await expect(audioPlayer.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible();
// Assert that "Play" button can be found
await expect(audioPlayer.getByRole("button", { name: "Play" })).not.toBeDisabled();
// Find and click "Reply" button
await threadTile.hover();
await threadTile.getByRole("button", { name: "Reply", exact: true }).click();
const composer = thread.locator(".mx_MessageComposer--compact");
// Assert that the reply preview contains audio ReplyTile the file info button
await expect(
composer.locator(".mx_ReplyPreview .mx_ReplyTile_audio .mx_MFileBody_info[role='button']"),
).toBeVisible();
// Select :smile: emoji and send it
await composer.getByTestId("basicmessagecomposer").fill(":smile:");
await composer.locator(".mx_Autocomplete_Completion[aria-selected='true']").click();
await composer.getByTestId("basicmessagecomposer").press("Enter");
// Assert that the file name is rendered on the file button
await expect(threadTile.locator(".mx_ReplyTile_audio .mx_MFileBody_info[role='button']")).toBeVisible();
});
});

View file

@ -0,0 +1,131 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import os from "node:os";
import path from "node:path";
import * as fsp from "node:fs/promises";
import * as fs from "node:fs";
import JSZip from "jszip";
import { test, expect } from "../../element-web-test";
// Based on https://github.com/Stuk/jszip/issues/466#issuecomment-2097061912
async function extractZipFileToPath(file: string, outputPath: string): Promise<JSZip> {
if (!fs.existsSync(outputPath)) {
fs.mkdirSync(outputPath, { recursive: true });
}
const data = await fsp.readFile(file);
const zip = await JSZip.loadAsync(data, { createFolders: true });
await new Promise<void>((resolve, reject) => {
let entryCount = 0;
let errorOut = false;
zip.forEach(() => {
entryCount++;
}); // there is no other way to count the number of entries within the zip file.
zip.forEach((relativePath, zipEntry) => {
if (errorOut) {
return;
}
const outputEntryPath = path.join(outputPath, relativePath);
if (zipEntry.dir) {
if (!fs.existsSync(outputEntryPath)) {
fs.mkdirSync(outputEntryPath, { recursive: true });
}
entryCount--;
if (entryCount === 0) {
resolve();
}
} else {
void zipEntry
.async("blob")
.then(async (content) => Buffer.from(await content.arrayBuffer()))
.then((buffer) => {
const stream = fs.createWriteStream(outputEntryPath);
stream.write(buffer, (error) => {
if (error) {
reject(error);
errorOut = true;
}
});
stream.on("finish", () => {
entryCount--;
if (entryCount === 0) {
resolve();
}
});
stream.end(); // extremely important on Windows. On Mac / Linux, not so much since those platforms allow multiple apps to read from the same file. Windows doesn't allow that.
})
.catch((e) => {
errorOut = true;
reject(e);
});
}
});
});
return zip;
}
test.describe("HTML Export", () => {
test.use({
displayName: "Alice",
room: async ({ app, user }, use) => {
const roomId = await app.client.createRoom({ name: "Important Room" });
await app.viewRoomByName("Important Room");
await use({ roomId });
},
});
test("should export html successfully and match screenshot", async ({ page, app, room }) => {
// Set a fixed time rather than masking off the line with the time in it: we don't need to worry
// about the width changing and we can actually test this line looks correct.
page.clock.setSystemTime(new Date("2024-01-01T00:00:00Z"));
// Send a bunch of messages to populate the room
for (let i = 1; i < 10; i++) {
const respone = await app.client.sendMessage(room.roomId, { body: `Testing ${i}`, msgtype: "m.text" });
if (i == 1) {
await app.client.reactToMessage(room.roomId, null, respone.event_id, "🙃");
}
}
// Wait for all the messages to be displayed
await expect(
page.locator(".mx_EventTile_last .mx_MTextBody .mx_EventTile_body").getByText("Testing 9"),
).toBeVisible();
await app.toggleRoomInfoPanel();
await page.getByRole("menuitem", { name: "Export Chat" }).click();
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Export", exact: true }).click();
const download = await downloadPromise;
const dirPath = path.join(os.tmpdir(), "html-export-test");
const zipPath = `${dirPath}.zip`;
await download.saveAs(zipPath);
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: [
// 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

@ -0,0 +1,98 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
import { SettingLevel } from "../../../src/settings/SettingLevel";
const CtrlOrMeta = process.platform === "darwin" ? "Meta" : "Control";
test.describe("Composer", () => {
test.use({
displayName: "Janet",
});
test.use({
room: async ({ app, user }, use) => {
const roomId = await app.client.createRoom({ name: "Composing Room" });
await app.viewRoomByName("Composing Room");
await use({ roomId });
},
});
test.beforeEach(async ({ room }) => {}); // trigger room fixture
test.describe("CIDER", () => {
test("sends a message when you click send or press Enter", async ({ page }) => {
const composer = page.getByRole("textbox", { name: "Send a message…" });
// Type a message
await composer.pressSequentially("my message 0");
// It has not been sent yet
await expect(page.locator(".mx_EventTile_body", { hasText: "my message 0" })).not.toBeVisible();
// Click send
await page.getByRole("button", { name: "Send message" }).click();
// It has been sent
await expect(
page.locator(".mx_EventTile_last .mx_EventTile_body", { hasText: "my message 0" }),
).toBeVisible();
// Type another and press Enter afterward
await composer.pressSequentially("my message 1");
await composer.press("Enter");
// It was sent
await expect(
page.locator(".mx_EventTile_last .mx_EventTile_body", { hasText: "my message 1" }),
).toBeVisible();
});
test("can write formatted text", async ({ page }) => {
const composer = page.getByRole("textbox", { name: "Send a message…" });
await composer.pressSequentially("my bold");
await composer.press(`${CtrlOrMeta}+KeyB`);
await composer.pressSequentially(" message");
await page.getByRole("button", { name: "Send message" }).click();
// Note: both "bold" and "message" are bold, which is probably surprising
await expect(page.locator(".mx_EventTile_body strong", { hasText: "bold message" })).toBeVisible();
});
test("should allow user to input emoji via graphical picker", async ({ page, app }) => {
await app.getComposer(false).getByRole("button", { name: "Emoji" }).click();
await page.getByTestId("mx_EmojiPicker").locator(".mx_EmojiPicker_item", { hasText: "😇" }).click();
await page.locator(".mx_ContextualMenu_background").click(); // Close emoji picker
await page.getByRole("textbox", { name: "Send a message…" }).press("Enter"); // Send message
await expect(page.locator(".mx_EventTile_body", { hasText: "😇" })).toBeVisible();
});
test.describe("when Control+Enter is required to send", () => {
test.beforeEach(async ({ app }) => {
await app.settings.setValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true);
});
test("only sends when you press Control+Enter", async ({ page }) => {
const composer = page.getByRole("textbox", { name: "Send a message…" });
// Type a message and press Enter
await composer.pressSequentially("my message 3");
await composer.press("Enter");
// It has not been sent yet
await expect(page.locator(".mx_EventTile_body", { hasText: "my message 3" })).not.toBeVisible();
// Press Control+Enter
await composer.press(`${CtrlOrMeta}+Enter`);
// It was sent
await expect(
page.locator(".mx_EventTile_last .mx_EventTile_body", { hasText: "my message 3" }),
).toBeVisible();
});
});
});
});

View file

@ -0,0 +1,349 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
import { SettingLevel } from "../../../src/settings/SettingLevel";
const CtrlOrMeta = process.platform === "darwin" ? "Meta" : "Control";
test.describe("Composer", () => {
test.use({
displayName: "Janet",
});
test.use({
room: async ({ app, user }, use) => {
const roomId = await app.client.createRoom({ name: "Composing Room" });
await app.viewRoomByName("Composing Room");
await use({ roomId });
},
});
test.beforeEach(async ({ room }) => {}); // trigger room fixture
test.describe("Rich text editor", () => {
test.use({
labsFlags: ["feature_wysiwyg_composer"],
});
test.describe("Commands", () => {
// TODO add tests for rich text mode
test.describe("Plain text mode", () => {
test("autocomplete behaviour tests", async ({ page }) => {
// Select plain text mode after composer is ready
await expect(page.locator("div[contenteditable=true]")).toBeVisible();
await page.getByRole("button", { name: "Hide formatting" }).click();
// Typing a single / displays the autocomplete menu and contents
await page.getByRole("textbox").press("/");
// Check that the autocomplete options are visible and there are more than 0 items
await expect(page.getByTestId("autocomplete-wrapper")).not.toBeEmpty();
// Entering `//` or `/ ` hides the autocomplete contents
// Add an extra slash for `//`
await page.getByRole("textbox").press("/");
await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty();
// Remove the extra slash to go back to `/`
await page.getByRole("textbox").press("Backspace");
await expect(page.getByTestId("autocomplete-wrapper")).not.toBeEmpty();
// Add a trailing space for `/ `
await page.getByRole("textbox").press(" ");
await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty();
// Typing a command that takes no arguments (/devtools) and selecting by click works
await page.getByRole("textbox").press("Backspace");
await page.getByRole("textbox").pressSequentially("dev");
await page.getByTestId("autocomplete-wrapper").getByText("/devtools").click();
// Check it has closed the autocomplete and put the text into the composer
await expect(page.getByTestId("autocomplete-wrapper")).not.toBeVisible();
await expect(page.getByRole("textbox").getByText("/devtools")).toBeVisible();
// Send the message and check the devtools dialog appeared, then close it
await page.getByRole("button", { name: "Send message" }).click();
await expect(page.getByRole("dialog").getByText("Developer Tools")).toBeVisible();
await page.getByRole("button", { name: "Close dialog" }).click();
// Typing a command that takes arguments (/spoiler) and selecting with enter works
await page.getByRole("textbox").pressSequentially("/spoil");
await expect(page.getByTestId("autocomplete-wrapper").getByText("/spoiler")).toBeVisible();
await page.getByRole("textbox").press("Enter");
// Check it has closed the autocomplete and put the text into the composer
await expect(page.getByTestId("autocomplete-wrapper")).not.toBeVisible();
await expect(page.getByRole("textbox").getByText("/spoiler")).toBeVisible();
// Enter some more text, then send the message
await page.getByRole("textbox").pressSequentially("this is the spoiler text ");
await page.getByRole("button", { name: "Send message" }).click();
// Check that a spoiler item has appeared in the timeline and locator the spoiler command text
await expect(page.locator("button.mx_EventTile_spoiler")).toBeVisible();
await expect(page.getByText("this is the spoiler text")).toBeVisible();
});
});
});
test.describe("Mentions", () => {
// TODO add tests for rich text mode
test.describe("Plain text mode", () => {
test.use({
botCreateOpts: {
displayName: "Bob",
},
});
test("autocomplete behaviour tests", async ({ page, app, bot: bob }) => {
// Set up a private room so we have another user to mention
await app.client.createRoom({
is_direct: true,
invite: [bob.credentials.userId],
});
await app.viewRoomByName("Bob");
// Select plain text mode after composer is ready
await expect(page.locator("div[contenteditable=true]")).toBeVisible();
await page.getByRole("button", { name: "Hide formatting" }).click();
// Typing a single @ does not display the autocomplete menu and contents
await page.getByRole("textbox").press("@");
await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty();
// Entering the first letter of the other user's name opens the autocomplete...
await page.getByRole("textbox").pressSequentially(bob.credentials.displayName.slice(0, 1));
// ...with the other user name visible, and clicking that username...
await page.getByTestId("autocomplete-wrapper").getByText(bob.credentials.displayName).click();
// ...inserts the username into the composer
const pill = page.getByRole("textbox").getByText(bob.credentials.displayName, { exact: false });
await expect(pill).toHaveAttribute("contenteditable", "false");
await expect(pill).toHaveAttribute("data-mention-type", "user");
// Send the message to clear the composer
await page.getByRole("button", { name: "Send message" }).click();
// Typing an @, then other user's name, then trailing space closes the autocomplete
await page.getByRole("textbox").pressSequentially(`@${bob.credentials.displayName} `);
await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty();
// Send the message to clear the composer
await page.getByRole("button", { name: "Send message" }).click();
// Moving the cursor back to an "incomplete" mention opens the autocomplete
await page
.getByRole("textbox")
.pressSequentially(`initial text @${bob.credentials.displayName.slice(0, 1)} abc`);
await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty();
// Move the cursor left by 4 to put it to: `@B| abc`, check autocomplete displays
await page.getByRole("textbox").press("ArrowLeft");
await page.getByRole("textbox").press("ArrowLeft");
await page.getByRole("textbox").press("ArrowLeft");
await page.getByRole("textbox").press("ArrowLeft");
await expect(page.getByTestId("autocomplete-wrapper")).not.toBeEmpty();
// Selecting the autocomplete option using Enter inserts it into the composer
await page.getByRole("textbox").press("Enter");
const pill2 = page.getByRole("textbox").getByText(bob.credentials.displayName, { exact: false });
await expect(pill2).toHaveAttribute("contenteditable", "false");
await expect(pill2).toHaveAttribute("data-mention-type", "user");
});
});
});
test("sends a message when you click send or press Enter", async ({ page }) => {
// Type a message
await page.locator("div[contenteditable=true]").pressSequentially("my message 0");
// It has not been sent yet
await expect(page.locator(".mx_EventTile_body", { hasText: "my message 0" })).not.toBeVisible();
// Click send
await page.getByRole("button", { name: "Send message" }).click();
// It has been sent
await expect(page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my message 0")).toBeVisible();
// Type another
await page.locator("div[contenteditable=true]").pressSequentially("my message 1");
// Send message
page.locator("div[contenteditable=true]").press("Enter");
// It was sent
await expect(page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my message 1")).toBeVisible();
});
test("sends only one message when you press Enter multiple times", async ({ page }) => {
// Type a message
await page.locator("div[contenteditable=true]").pressSequentially("my message 0");
// It has not been sent yet
await expect(page.locator(".mx_EventTile_body", { hasText: "my message 0" })).not.toBeVisible();
// Click send
await page.locator("div[contenteditable=true]").press("Enter");
await page.locator("div[contenteditable=true]").press("Enter");
await page.locator("div[contenteditable=true]").press("Enter");
// It has been sent
await expect(page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my message 0")).toBeVisible();
await expect(page.locator(".mx_EventTile_last .mx_EventTile_body")).toHaveCount(1);
});
test("can write formatted text", async ({ page }) => {
await page.locator("div[contenteditable=true]").pressSequentially("my ");
await page.locator("div[contenteditable=true]").press(`${CtrlOrMeta}+KeyB`);
await page.locator("div[contenteditable=true]").pressSequentially("bold");
await page.locator("div[contenteditable=true]").press(`${CtrlOrMeta}+KeyB`);
await page.locator("div[contenteditable=true]").pressSequentially(" message");
await page.getByRole("button", { name: "Send message" }).click();
await expect(page.locator(".mx_EventTile_body strong").getByText("bold")).toBeVisible();
});
test.describe("when Control+Enter is required to send", () => {
test.beforeEach(async ({ app }) => {
await app.settings.setValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true);
});
test("only sends when you press Control+Enter", async ({ page }) => {
// Type a message and press Enter
await page.locator("div[contenteditable=true]").pressSequentially("my message 3");
await page.locator("div[contenteditable=true]").press("Enter");
// It has not been sent yet
await expect(page.locator(".mx_EventTile_body", { hasText: "my message 3" })).not.toBeVisible();
// Press Control+Enter
await page.locator("div[contenteditable=true]").press("Control+Enter");
// It was sent
await expect(
page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my message 3"),
).toBeVisible();
});
});
test.describe("links", () => {
test("create link with a forward selection", async ({ page }) => {
// Type a message
await page.locator("div[contenteditable=true]").pressSequentially("my message 0");
await page.locator("div[contenteditable=true]").press(`${CtrlOrMeta}+A`);
// Open link modal
await page.getByRole("button", { name: "Link" }).click();
// Fill the link field
await page.getByRole("textbox", { name: "Link" }).pressSequentially("https://matrix.org/");
// Click on save
await page.getByRole("button", { name: "Save" }).click();
// Send the message
await page.getByRole("button", { name: "Send message" }).click();
// It was sent
await expect(page.locator(".mx_EventTile_body a").getByText("my message 0")).toBeVisible();
await expect(page.locator(".mx_EventTile_body a")).toHaveAttribute(
"href",
new RegExp("https://matrix.org/"),
);
});
});
test.describe("Drafts", () => {
test("drafts with rich and plain text", async ({ page, app }) => {
// Set up a second room to swtich to, to test drafts
const firstRoomname = "Composing Room";
const secondRoomname = "Second Composing Room";
await app.client.createRoom({ name: secondRoomname });
// Composer is visible
const composer = page.locator("div[contenteditable=true]");
await expect(composer).toBeVisible();
// Type some formatted text
await composer.pressSequentially("my ");
await composer.press(`${CtrlOrMeta}+KeyB`);
await composer.pressSequentially("bold");
// Change to plain text mode
await page.getByRole("button", { name: "Hide formatting" }).click();
// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);
// assert the markdown
await expect(page.locator("div[contenteditable=true]", { hasText: "my __bold__" })).toBeVisible();
// Change to plain text mode and assert the markdown
await page.getByRole("button", { name: "Show formatting" }).click();
// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);
// Send the message and assert the message
await page.getByRole("button", { name: "Send message" }).click();
await expect(page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my bold")).toBeVisible();
});
test("draft with replies", async ({ page, app }) => {
// Set up a second room to swtich to, to test drafts
const firstRoomname = "Composing Room";
const secondRoomname = "Second Composing Room";
await app.client.createRoom({ name: secondRoomname });
// Composer is visible
const composer = page.locator("div[contenteditable=true]");
await expect(composer).toBeVisible();
// Send a message
await composer.pressSequentially("my first message");
await page.getByRole("button", { name: "Send message" }).click();
// Click reply
const tile = page.locator(".mx_EventTile_last");
await tile.hover();
await tile.getByRole("button", { name: "Reply", exact: true }).click();
// Type reply text
await composer.pressSequentially("my reply");
// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);
// Assert reply mode and reply text
await expect(page.getByText("Replying")).toBeVisible();
await expect(page.locator("div[contenteditable=true]", { hasText: "my reply" })).toBeVisible();
});
test("draft in threads", async ({ page, app }) => {
// Set up a second room to swtich to, to test drafts
const firstRoomname = "Composing Room";
const secondRoomname = "Second Composing Room";
await app.client.createRoom({ name: secondRoomname });
// Composer is visible
const composer = page.locator("div[contenteditable=true]");
await expect(composer).toBeVisible();
// Send a message
await composer.pressSequentially("my first message");
await page.getByRole("button", { name: "Send message" }).click();
// Click reply
const tile = page.locator(".mx_EventTile_last");
await tile.hover();
await tile.getByRole("button", { name: "Reply in thread" }).click();
const thread = page.locator(".mx_ThreadView");
const threadComposer = thread.locator("div[contenteditable=true]");
// Type threaded text
await threadComposer.pressSequentially("my threaded message");
// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);
// Assert threaded draft
await expect(
thread.locator("div[contenteditable=true]", { hasText: "my threaded message" }),
).toBeVisible();
});
});
});
});

View file

@ -0,0 +1,34 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("Create Room", () => {
test.use({ displayName: "Jim" });
test("should allow us to create a public room with name, topic & address set", async ({ page, user, app }) => {
const name = "Test room 1";
const topic = "This room is dedicated to this test and this test only!";
const dialog = await app.openCreateRoomDialog();
// Fill name & topic
await dialog.getByRole("textbox", { name: "Name" }).fill(name);
await dialog.getByRole("textbox", { name: "Topic" }).fill(topic);
// Change room to public
await dialog.getByRole("button", { name: "Room visibility" }).click();
await dialog.getByRole("option", { name: "Public room" }).click();
// Fill room address
await dialog.getByRole("textbox", { name: "Room address" }).fill("test-room-1");
// Submit
await dialog.getByRole("button", { name: "Create room" }).click();
await expect(page).toHaveURL(/\/#\/room\/#test-room-1:localhost/);
const header = page.locator(".mx_RoomHeader");
await expect(header).toContainText(name);
});
});

View file

@ -0,0 +1,112 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
async function expectBackupVersionToBe(page: Page, version: string) {
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText(
version + " (Algorithm: m.megolm_backup.v1.curve25519-aes-sha2)",
);
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(6) td")).toHaveText(version);
}
test.describe("Backups", () => {
test.use({
displayName: "Hanako",
});
test("Create, delete and recreate a keys backup", async ({ page, user, app }, workerInfo) => {
// Create a backup
const securityTab = await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
const currentDialogLocator = page.locator(".mx_Dialog");
// It's the first time and secure storage is not set up, so it will create one
await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click();
// copy the recovery key to use it later
const securityKey = await app.getClipboard();
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Done", exact: true }).click();
// Open the settings again
await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
// expand the advanced section to see the active version in the reports
await page
.locator(".mx_Dialog .mx_SettingsSubsection_content details .mx_SecureBackupPanel_advanced")
.locator("..")
.click();
await expectBackupVersionToBe(page, "1");
await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible();
// Delete it
await currentDialogLocator.getByTestId("dialog-primary-button").click(); // Click "Delete Backup"
// Create another
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Security Key" })).toBeVisible();
await currentDialogLocator.getByLabel("Security Key").fill(securityKey);
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
// Should be successful
await expect(currentDialogLocator.getByRole("heading", { name: "Success!" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "OK", exact: true }).click();
// Open the settings again
await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
// expand the advanced section to see the active version in the reports
await page
.locator(".mx_Dialog .mx_SettingsSubsection_content details .mx_SecureBackupPanel_advanced")
.locator("..")
.click();
await expectBackupVersionToBe(page, "2");
// ==
// Ensure that if you don't have the secret storage passphrase the backup won't be created
// ==
// First delete version 2
await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible();
// Click "Delete Backup"
await currentDialogLocator.getByTestId("dialog-primary-button").click();
// Try to create another
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Security Key" })).toBeVisible();
// But cancel the security key dialog, to simulate not having the secret storage passphrase
await currentDialogLocator.getByTestId("dialog-cancel-button").click();
await expect(currentDialogLocator.getByRole("heading", { name: "Starting backup…" })).toBeVisible();
// check that it failed
await expect(currentDialogLocator.getByText("Unable to create key backup")).toBeVisible();
// cancel
await currentDialogLocator.getByTestId("dialog-cancel-button").click();
// go back to the settings to check that no backup was created (the setup button should still be there)
await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("button", { name: "Set up", exact: true })).toBeVisible();
});
});

View file

@ -0,0 +1,27 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
import { logIntoElement } from "./utils";
test.describe("Complete security", () => {
test.use({
displayName: "Jeff",
});
test("should go straight to the welcome screen if we have no signed device", async ({
page,
homeserver,
credentials,
}) => {
await logIntoElement(page, homeserver, credentials);
await expect(page.getByText("Welcome Jeff", { exact: true })).toBeVisible();
});
// see also "Verify device during login with SAS" in `verifiction.spec.ts`.
});

View file

@ -0,0 +1,245 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022-2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import type { Page } from "@playwright/test";
import { expect, test } from "../../element-web-test";
import { autoJoin, copyAndContinue, createSharedRoomWithUser, enableKeyBackup, verify } from "./utils";
import { Bot } from "../../pages/bot";
import { ElementAppPage } from "../../pages/ElementAppPage";
const checkDMRoom = async (page: Page) => {
const body = page.locator(".mx_RoomView_body");
await expect(body.getByText("Alice created this DM.")).toBeVisible();
await expect(body.getByText("Alice invited Bob")).toBeVisible({ timeout: 1000 });
await expect(body.locator(".mx_cryptoEvent").getByText("Encryption enabled")).toBeVisible();
};
const startDMWithBob = async (page: Page, bob: Bot) => {
await page.locator(".mx_RoomList").getByRole("button", { name: "Start chat" }).click();
await page.getByTestId("invite-dialog-input").fill(bob.credentials.userId);
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();
};
const testMessages = async (page: Page, bob: Bot, bobRoomId: string) => {
// check the invite message
await expect(
page.locator(".mx_EventTile", { hasText: "Hey!" }).locator(".mx_EventTile_e2eIcon_warning"),
).not.toBeVisible();
// Bob sends a response
await bob.sendMessage(bobRoomId, "Hoo!");
await expect(
page.locator(".mx_EventTile", { hasText: "Hoo!" }).locator(".mx_EventTile_e2eIcon_warning"),
).not.toBeVisible();
};
const bobJoin = async (page: Page, bob: Bot) => {
// Wait for Bob to get the invite
await bob.evaluate(async (cli) => {
const bobRooms = cli.getRooms();
if (!bobRooms.length) {
await new Promise<void>((resolve) => {
const onMembership = (_event) => {
cli.off(window.matrixcs.RoomMemberEvent.Membership, onMembership);
resolve();
};
cli.on(window.matrixcs.RoomMemberEvent.Membership, onMembership);
});
}
});
const roomId = await bob.joinRoomByName("Alice");
await expect(page.getByText("Bob joined the room")).toBeVisible();
// Even though Alice has seen Bob's join event, Bob may not have done so yet. Wait for the sync to arrive.
await bob.awaitRoomMembership(roomId);
return roomId;
};
test.describe("Cryptography", function () {
test.use({
displayName: "Alice",
botCreateOpts: {
displayName: "Bob",
autoAcceptInvites: false,
},
});
for (const isDeviceVerified of [true, false]) {
test.describe(`setting up secure key backup should work isDeviceVerified=${isDeviceVerified}`, () => {
/**
* Verify that the `m.cross_signing.${keyType}` key is available on the account data on the server
* @param keyType
*/
async function verifyKey(app: ElementAppPage, keyType: string) {
const accountData: { encrypted: Record<string, Record<string, string>> } = await app.client.evaluate(
(cli, keyType) => cli.getAccountDataFromServer(`m.cross_signing.${keyType}`),
keyType,
);
expect(accountData.encrypted).toBeDefined();
const keys = Object.keys(accountData.encrypted);
const key = accountData.encrypted[keys[0]];
expect(key.ciphertext).toBeDefined();
expect(key.iv).toBeDefined();
expect(key.mac).toBeDefined();
}
test("by recovery code", async ({ page, app, user: aliceCredentials }) => {
// Verified the device
if (isDeviceVerified) {
await app.client.bootstrapCrossSigning(aliceCredentials);
}
await page.route("**/_matrix/client/v3/keys/signatures/upload", async (route) => {
// We delay this API otherwise the `Setting up keys` may happen too quickly and cause flakiness
await new Promise((resolve) => setTimeout(resolve, 500));
await route.continue();
});
await app.settings.openUserSettings("Security & Privacy");
await page.getByRole("button", { name: "Set up Secure Backup" }).click();
const dialog = page.locator(".mx_Dialog");
// Recovery key is selected by default
await dialog.getByRole("button", { name: "Continue" }).click();
await copyAndContinue(page);
// If the device is unverified, there should be a "Setting up keys" step; however, it
// can be quite quick, and playwright can miss it, so we can't test for it.
// Either way, we end up at a success dialog:
await expect(dialog.getByText("Secure Backup successful")).toBeVisible();
await dialog.getByRole("button", { name: "Done" }).click();
await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible();
// Verify that the SSSS keys are in the account data stored in the server
await verifyKey(app, "master");
await verifyKey(app, "self_signing");
await verifyKey(app, "user_signing");
});
test("by passphrase", async ({ page, app, user: aliceCredentials }) => {
// Verified the device
if (isDeviceVerified) {
await app.client.bootstrapCrossSigning(aliceCredentials);
}
await app.settings.openUserSettings("Security & Privacy");
await page.getByRole("button", { name: "Set up Secure Backup" }).click();
const dialog = page.locator(".mx_Dialog");
// Select passphrase option
await dialog.getByText("Enter a Security Phrase").click();
await dialog.getByRole("button", { name: "Continue" }).click();
// Fill passphrase input
await dialog.locator("input").fill("new passphrase for setting up a secure key backup");
await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
// Confirm passphrase
await dialog.locator("input").fill("new passphrase for setting up a secure key backup");
await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
await copyAndContinue(page);
await expect(dialog.getByText("Secure Backup successful")).toBeVisible();
await dialog.getByRole("button", { name: "Done" }).click();
await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible();
// Verify that the SSSS keys are in the account data stored in the server
await verifyKey(app, "master");
await verifyKey(app, "self_signing");
await verifyKey(app, "user_signing");
});
});
}
test("Can reset cross-signing keys", async ({ page, app, user: aliceCredentials }) => {
const secretStorageKey = await enableKeyBackup(app);
// Fetch the current cross-signing keys
async function fetchMasterKey() {
return await test.step("Fetch master key from server", async () => {
const k = await app.client.evaluate(async (cli) => {
const userId = cli.getUserId();
const keys = await cli.downloadKeysForUsers([userId]);
return Object.values(keys.master_keys[userId].keys)[0];
});
console.log(`fetchMasterKey: ${k}`);
return k;
});
}
const masterKey1 = await fetchMasterKey();
// Find the "reset cross signing" button, and click it
await app.settings.openUserSettings("Security & Privacy");
await page.locator("div.mx_CrossSigningPanel_buttonRow").getByRole("button", { name: "Reset" }).click();
// Confirm
await page.getByRole("button", { name: "Clear cross-signing keys" }).click();
// Enter the 4S key
await page.getByPlaceholder("Security Key").fill(secretStorageKey);
await page.getByRole("button", { name: "Continue" }).click();
// Enter the password
await page.getByPlaceholder("Password").fill(aliceCredentials.password);
await page.getByRole("button", { name: "Continue" }).click();
await expect(async () => {
const masterKey2 = await fetchMasterKey();
expect(masterKey1).not.toEqual(masterKey2);
}).toPass();
// The dialog should have gone away
await expect(page.locator(".mx_Dialog")).toHaveCount(1);
});
test("creating a DM should work, being e2e-encrypted / user verification", async ({
page,
app,
bot: bob,
user: aliceCredentials,
}) => {
await app.client.bootstrapCrossSigning(aliceCredentials);
await startDMWithBob(page, bob);
// send first message
await page.getByRole("textbox", { name: "Send a message…" }).fill("Hey!");
await page.getByRole("textbox", { name: "Send a message…" }).press("Enter");
await checkDMRoom(page);
const bobRoomId = await bobJoin(page, bob);
await testMessages(page, bob, bobRoomId);
await verify(app, bob);
// Assert that verified icon is rendered
await page.getByTestId("base-card-back-button").click();
await page.getByLabel("Room info").nth(1).click();
await expect(page.locator('.mx_RoomSummaryCard_badges [data-kind="green"]')).toContainText("Encrypted");
// Take a snapshot of RoomSummaryCard with a verified E2EE icon
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("RoomSummaryCard-with-verified-e2ee.png");
});
test("should allow verification when there is no existing DM", async ({
page,
app,
bot: bob,
user: aliceCredentials,
}) => {
await app.client.bootstrapCrossSigning(aliceCredentials);
await autoJoin(bob);
// we need to have a room with the other user present, so we can open the verification panel
await createSharedRoomWithUser(app, bob.credentials.userId);
await verify(app, bob);
});
});

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