Compare commits

...

65 Commits

Author SHA1 Message Date
Half-Shot
d7a185baea Move props into IProps 2025-03-17 10:07:36 +00:00
Half-Shot
93009d4613 Add a comment 2025-03-17 10:01:12 +00:00
Half-Shot
71257d97e7 Merge remote-tracking branch 'origin/develop' into hs/add-hide-image-button 2025-03-17 09:59:37 +00:00
Half-Shot
60eeb8a7de Support functional components for message body rendering. 2025-03-17 09:59:25 +00:00
Michael Telatynski
ff1da50dd9 Move a bunch of shared playwright code into @element-hq/element-web-playwright-common (#29477)
* Move a bunch of shared playwright code into @element-hq/element-web-playwright-common

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Remove stale devDep

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update playwright-common

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update screenshot

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix testcontainers version

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-17 09:16:45 +00:00
Half-Shot
571a2e373d Fixup tests 2025-03-17 09:06:58 +00:00
Will Hunt
4af5d4ac80 Do not lint playwright files. (#29510) 2025-03-17 09:05:21 +00:00
Half-Shot
d0b8564660 Fixup MImageBody test 2025-03-17 08:47:43 +00:00
Will Hunt
28ea91566a Merge branch 'develop' into hs/add-hide-image-button 2025-03-17 08:27:55 +00:00
Half-Shot
ef32747473 Drop setting hook usage. 2025-03-17 08:27:15 +00:00
Half-Shot
7696516e8b Use a hook for media visibility. 2025-03-17 08:22:19 +00:00
ElementRobot
a858fed321 [create-pull-request] automated change (#29509)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-03-17 06:22:14 +00:00
Florian Duros
f3dbe81ef4 New room list: add more options menu on room list item (#29445)
* refactor(room list item): rename `RoomListCell` into `RoomListItemView`

* refactor(room list item): move open room action to new room list item view model

* feat(hover menu): add `hasAccessToOptionsMenu`

* feat(hover menu): add to `RoomListItemViewModel` the condition to display or not the hover menu

* feat(hover menu): add view model for the hover menu

* feat(hover menu): add hover menu view

* feat(hover menu): add hover menu to room list item

* feat(hover menu): update i18n

* test(view model list item): update test and add test to `showHoverMenu`

* test(room list): update snapshot

* test(room list item menu): add tests for view model

* test(room list item menu): add tests for view

* test(room list item): add tests

* test(e2e): add tests for more options menu

* chore: update compound web

* test(e2e): fix typo
2025-03-14 16:22:45 +00:00
Florian Duros
ceba762caf New room list: fix compose menu action in space (#29500)
* fix(room list header): in view model, can create room in space if user has the right. Display the compose menu if the user can create room or video room

* fix(room list header): can create directly chat if compose menu is disabled

* test(room list header): add tests for view model

* test(room list header): update tests of view

* test(room list header): update list test
2025-03-14 16:12:30 +00:00
R Midhun Suresh
9fb52e984c RoomListViewModel: Provide a way to resort the room list and track the active sort method (#29499)
* Add a hook that deals with the sorting behaviour

Hook will provide a function to sort the list and also provides a state
which tracks the currently active sort option.

* Use hook in vm

* Write test for the vm

* Fix broken view tests
2025-03-14 15:10:34 +00:00
Florian Duros
c31f5521ec feat(room list): change *All rooms* meta space name to *All Chats* (#29498) 2025-03-14 12:47:40 +00:00
Will Hunt
66d9d717c4 Add setting to hide avatars of rooms you have been invited to. (#29497)
* Add ability to block images of rooms you have been invited to.

* strings

* Add tests

* fix snapshot

* tweaks

* lint
2025-03-14 12:03:09 +00:00
ElementRobot
4e3daa5df5 [create-pull-request] automated change (#29495)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-03-14 11:59:45 +00:00
David Baker
f9a0bb2904 Add analytics constant to encryption tab (#29489)
As it got stuck in review for 5 months and we all forgot about it
2025-03-14 10:06:44 +00:00
R Midhun Suresh
f4b03a1b06 Room List Store: Save preferred sorting algorithm and use that on app launch (#29493)
* Add `type` property to Sorter

So that we can uniquely identify any given sorting algorithm.

* Add a getter for the active sort algorithm

* Define a setting to store the sorting algorithm

* Add a method to resort the list of rooms

- Just one method where you specify the sorting algorithm by type.
- Persist the new sorting algorithm using SettingsStore.

* On startup, use preferred sorter

* Add tests
2025-03-14 09:41:04 +00:00
David Baker
be3778bef0 Add key storage toggle to Encryption settings (#29310)
* Add key storage toggle to Encryption settings

* Keys in the acceptable order

* Fix some tests

* Fix import

* Fix toast showing condition

* Fix import order

* Fix playwright tests

* Fix bits lost in merge

* Add key storage delete confirm screen

* Fix hardcoded Element string

* Fix type imports

* Fix tests

* Tests for key storage delete panel

* Fix test

* Type import

* Test for the view model

* Fix type import

* Actually fix type imports

* Test updating

* Add playwright test & clarify slightly confusing comment

* Show the advnced section whatever the state of key storage

* Update screenshots

* Copy css to its own file

* Add missing doc & merge loading states

* Add tsdoc & loading alt text to spinner

* Turn comments into proper tsdoc

* Switch to TypedEventEmitter and remove unnecessary loading state

* Add screenshot

* Use higher level interface

* Merge the two hooks in EncryptionUserSettingsTab

* Remove unused import

* Don't check key backup enabled state separately

as we don't need it for all the screens

* Update snapshot

* Use fixed recovery key function

* Amalgamate duplicated CSS files

* Have "key storage disabled" as a separate state

* Update snapshot

* Fix... bad merge?

* Add backup enabled mock to more tests

* More snapshots

* Use defer util

* Update to use EncryptionCardButtons

* Update snapshots

* Use EncryptionCardEmphasisedContent

* Update snapshots

* Update snapshot

* Try screenshot from CI playwright

* Try playwright screenshots again

* More screenshots

* Rename to match files

* Test that 4S secrets are deleted

* Make description clearer

* Fix typo & move related states together

* Add comment

* More comments

* Fix hook docs

* restoreAllMocks

* Update snapshot

because pulling in upstream has caused IDs to shift

* Switch icon

as apparenty the error icon has changed

* Update snapshot

* Missing copyright

* Re-order states

and also sort out indenting

* Remove phantom space

* Clarify 'button'

* Clarify docs more

* Explain thinking behind updating

* Switch to getActiveBackupVersion

which checks that key backup is happining on this device, which is
consistent with EX.

* Add use of Key Storage Panel

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Change key storage panel to be consistent

ie. using getActiveBackupVersion(), and add comment

* Add tsdoc

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Use BACKUP_DISABLED_ACCOUNT_DATA_KEY in more places

* Expand doc

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Undo random yarn lock change

* Use aggregate method for disabling key storage

in https://github.com/matrix-org/matrix-js-sdk/pull/4742

* Fix tests

* Use key backup status event to update

* Comment formatting

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Fix comment & put check inside if statement

* Add comment

* Prettier

* Fix comment

* Update snapshot

Which has gained nowrap due to 917d53a56f

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2025-03-14 08:52:41 +00:00
ElementRobot
973d639d01 [create-pull-request] automated change (#29494)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-03-14 06:15:40 +00:00
Florian Duros
20d8abf7c2 New room list: add primary filters (#29481)
* feat(room filter): add component for the primary filters

* feat(room filter): add filter component to room list view

* test(room filter): add tests to primary filters

* test: update snapshots

* test(e2e): update snapshots

* test(e2e): add tests for primary filters

* refactor: change aria-label of primary filters
2025-03-13 17:29:57 +00:00
Will Hunt
46b1234a1d Merge branch 'develop' into hs/add-hide-image-button 2025-03-13 16:49:27 +00:00
Tulir Asokan
fda658182a Implement MSC4142: Remove unintentional intentional mentions in replies (#28209)
* Implement MSC4142: Remove unintentional intentional mentions in replies

* Fix comment
2025-03-13 16:00:54 +00:00
renovate[bot]
9bfea92b66 Update testcontainers-node monorepo to v10.19.0 (#29491)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-13 15:46:27 +00:00
Michael Telatynski
962136d453 Avoid using /tmp/ for bind mounts and non-tmpfs binds (#29488)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-13 14:51:01 +00:00
Florian Duros
917d53a56f Add wrap props to flex component (#29480)
* feat(flex): add wrap props to flex component

* test: update snapshot
2025-03-13 13:32:48 +00:00
Máté Gyöngyösi
e44ca88a7e Change ToggleHiddenEventVisibility & GoToHome KeyBindingActions (#29374)
The current keyboard shortcuts for GoToHome and ToggleHiddenEventVisibility are:

|                             	| other        	| macOS        	|
|-----------------------------	|--------------	|--------------	|
| GoToHome                    	| Ctrl–Alt–H   	| Ctrl–Shift–H 	|
| ToggleHiddenEventVisibility 	| Ctrl–Shift–H 	| Cmd–Shift–H  	|

This removes both distinctions for macOS in order ToggleHiddenEventVisibility not to conflict with...
1. the built-in Safari keyboard shortcut for opening the Home page (Cmd–Shift–H)
2. the KeyBindingAction for GoToHome.

Co-authored-by: Florian Duros <florianduros@element.io>
2025-03-13 09:46:29 +00:00
renovate[bot]
cb7d77de45 Update dependency @babel/runtime to v7.26.10 [SECURITY] (#29478)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-12 12:45:21 +00:00
renovate[bot]
cd6737942f Update dependency @babel/runtime to v7.26.10 [SECURITY] (#29478)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-12 12:45:21 +00:00
renovate[bot]
a058d85c21 Update playwright to v1.51.0 (#29469)
* Update playwright to v1.51.0

* Update screenshots

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-12 11:50:10 +00:00
Andy Balaam
bf6ae73d39 White background for 'They do not match' button (#29470) 2025-03-12 08:04:17 +00:00
ElementRobot
273cdf41e9 [create-pull-request] automated change (#29476)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-03-12 06:20:02 +00:00
ElementRobot
3ab3041c45 [create-pull-request] automated change (#29475)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-03-12 06:14:50 +00:00
Ben Banfield-Zanin
2052080d7d Fix Docker Healthcheck (#29471)
* Correct test for docker container being healthy

* Correct docker healthcheck for busybox wget

* Repeatedly check the health state of the docker container

* Use until loop & rely on timeout-minutes

* Fix check to look at healthy state

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-11 18:04:33 +00:00
Half-Shot
b9c0d63e3e lint 2025-03-11 15:57:09 +00:00
Half-Shot
cf7e52c6fc lint 2025-03-11 15:55:30 +00:00
Half-Shot
e87eb127ee Add tests for HideActionButton 2025-03-11 15:51:05 +00:00
Half-Shot
83e421daf2 appese prettier 2025-03-11 15:20:56 +00:00
Half-Shot
d6fb24dea7 i18n 2025-03-11 15:14:46 +00:00
Half-Shot
a518c8d662 add type 2025-03-11 15:14:25 +00:00
Half-Shot
c759e516bd docs fixes 2025-03-11 15:13:42 +00:00
Half-Shot
c8b55c3dfe add description for migration 2025-03-11 15:11:14 +00:00
Half-Shot
7197093744 Fixup and add tests 2025-03-11 15:08:48 +00:00
dependabot[bot]
cc95d154fb Bump axios from 1.8.1 to 1.8.2 (#29468)
Bumps [axios](https://github.com/axios/axios) from 1.8.1 to 1.8.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.8.1...v1.8.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-11 15:03:42 +00:00
RiotRobot
4e696d2dc6 Reset matrix-js-sdk back to develop branch 2025-03-11 14:44:08 +00:00
RiotRobot
610b14adc1 Merge branch 'master' into develop 2025-03-11 14:43:56 +00:00
RiotRobot
324dd5a858 v1.11.95 2025-03-11 14:40:55 +00:00
RiotRobot
bc4bc6c25e Upgrade dependency to matrix-js-sdk@37.1.0 2025-03-11 14:36:16 +00:00
R Midhun Suresh
3f3fba99eb RoomListViewModel: Support secondary filters in the view model (#29465)
* Support secondary filters in the view model

* Write view model tests

* Fix RoomList test

* Add more comments
2025-03-11 13:29:55 +00:00
Half-Shot
4e34adb854 Tweaks to MImageBody to support new setting. 2025-03-11 11:32:49 +00:00
Half-Shot
72c2a3eb07 Add an action button to hide settings. 2025-03-11 11:32:26 +00:00
Half-Shot
4d290461c4 Add a migration path 2025-03-11 11:32:17 +00:00
Half-Shot
0cc06450d7 Add new setting showMediaEventIds 2025-03-11 11:32:10 +00:00
Half-Shot
9376d71831 Move useSettingsValueWithSetter to useSettings 2025-03-11 11:31:51 +00:00
ElementRobot
26a17f9314 [create-pull-request] automated change (#29455)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-11 10:34:05 +00:00
Half-Shot
6d5442a87b start hide 2025-03-11 10:13:06 +00:00
R Midhun Suresh
fd91e78152 RoomListViewModel: Support primary filters in the view model (#29454)
* Track available filters and expose this info from the vm

- Adds a separate hook that tracks the filtered rooms and the available
  filters.
- When secondary filters are added, some of the primary filters will be
  selectively hidden. So track this info in the vm.

* Write tests

* Fix typescript error

* Fix translation

* Explain what a primary filter is
2025-03-10 13:23:38 +00:00
R Midhun Suresh
af476905b6 Room List Store: Fetch rooms after space store is ready + attach store to window (#29453)
* Attach the new store to window

* Fetch rooms after space store is ready

If we fetch a list of rooms and then wait for the space store to be
ready, we will need some way of handling the onAction calls we get
while we wait. These calls are dropped now.
2025-03-10 12:33:06 +00:00
R Midhun Suresh
da87bbe854 Room List Store: Fix bug where left rooms appear in room list (#29452)
* Write failing test

* Remove room when membership changes from JOIN to LEAVE
2025-03-10 11:58:12 +00:00
R Midhun Suresh
47976447b5 Room List Store: Implement secondary filters (#29458)
* Implement the secondary filters

* Use the new filters in the store

* Write tests
2025-03-10 11:37:37 +00:00
Will Hunt
53065f9437 Add E2E test for quick settings dialog (#29441)
* Update quick settings menu to use a11y roles.

* Add e2e test to test quick menu rendering

* Use a testid for now.

* lint lint

* Revert aria changes

* revert managed

* write screenshot
2025-03-10 09:12:38 +00:00
RiotRobot
c0d14daa17 v1.11.95-rc.0 2025-03-04 12:51:06 +00:00
RiotRobot
ed35a7cba4 Upgrade dependency to matrix-js-sdk@37.1.0-rc.0 2025-03-04 12:46:15 +00:00
202 changed files with 5114 additions and 2917 deletions

View File

@@ -8,3 +8,6 @@ src/component-index.js
# Auto-generated file
src/modules.ts
src/modules.js
# Test result files
/playwright/test-results/
/playwright/html-report/

View File

@@ -61,6 +61,7 @@ jobs:
- name: Test the image
env:
IMAGEID: ${{ steps.test-build.outputs.imageid }}
timeout-minutes: 2
run: |
set -x
@@ -76,7 +77,7 @@ jobs:
--rm \
-e "ELEMENT_WEB_PORT=$ELEMENT_WEB_PORT" \
-dp "$ELEMENT_WEB_PORT:$ELEMENT_WEB_PORT" \
-v $(pwd)/modules:/tmp/element-web-modules \
-v $(pwd)/modules:/modules \
"$IMAGEID" \
)
@@ -86,7 +87,9 @@ jobs:
test "$MODULE_0" = "/${MODULE_PATH}"
# Check healthcheck
test "$(docker inspect -f {{.State.Running}} $CONTAINER_ID)" == "true"
until test "$(docker inspect -f {{.State.Health.Status}} $CONTAINER_ID)" == "healthy"; do
sleep 1
done
# Clean up
docker stop "$CONTAINER_ID"

View File

@@ -1,3 +1,28 @@
Changes in [1.11.95](https://github.com/element-hq/element-web/releases/tag/v1.11.95) (2025-03-11)
==================================================================================================
## ✨ Features
* Room List Store: Filter rooms by active space ([#29399](https://github.com/element-hq/element-web/pull/29399)). Contributed by @MidhunSureshR.
* Room List - Update the room list store on actions from the dispatcher ([#29397](https://github.com/element-hq/element-web/pull/29397)). Contributed by @MidhunSureshR.
* Room List - Implement a minimal view model ([#29357](https://github.com/element-hq/element-web/pull/29357)). Contributed by @MidhunSureshR.
* New room list: add space menu in room header ([#29352](https://github.com/element-hq/element-web/pull/29352)). Contributed by @florianduros.
* Room List - Store sorted rooms in skip list ([#29345](https://github.com/element-hq/element-web/pull/29345)). Contributed by @MidhunSureshR.
* New room list: add dial to search section ([#29359](https://github.com/element-hq/element-web/pull/29359)). Contributed by @florianduros.
* New room list: add compose menu for spaces in header ([#29347](https://github.com/element-hq/element-web/pull/29347)). Contributed by @florianduros.
* Use EditInPlace control for Identity Server picker to improve a11y ([#29280](https://github.com/element-hq/element-web/pull/29280)). Contributed by @Half-Shot.
* First step to add header to new room list ([#29320](https://github.com/element-hq/element-web/pull/29320)). Contributed by @florianduros.
* Add Windows 64-bit arm link and remove 32-bit link on compatibility page ([#29312](https://github.com/element-hq/element-web/pull/29312)). Contributed by @t3chguy.
* Honour the backup disable flag from Element X ([#29290](https://github.com/element-hq/element-web/pull/29290)). Contributed by @dbkr.
## 🐛 Bug Fixes
* Fix edited code block width ([#29394](https://github.com/element-hq/element-web/pull/29394)). Contributed by @florianduros.
* new room list: keep space name in one line in header ([#29369](https://github.com/element-hq/element-web/pull/29369)). Contributed by @florianduros.
* Dismiss "Key storage out of sync" toast when secrets received ([#29348](https://github.com/element-hq/element-web/pull/29348)). Contributed by @richvdh.
* Minor CSS fixes for the new room list ([#29334](https://github.com/element-hq/element-web/pull/29334)). Contributed by @florianduros.
* Add padding to room header icon ([#29271](https://github.com/element-hq/element-web/pull/29271)). Contributed by @langleyd.
Changes in [1.11.94](https://github.com/element-hq/element-web/releases/tag/v1.11.94) (2025-02-27)
==================================================================================================
## 🐛 Bug Fixes

View File

@@ -47,4 +47,4 @@ USER nginx
# HTTP listen port
ENV ELEMENT_WEB_PORT=80
HEALTHCHECK --start-period=5s CMD wget --retry-connrefused --tries=5 -q --wait=3 --spider http://localhost:$ELEMENT_WEB_PORT/config.json
HEALTHCHECK --start-period=5s CMD wget -q --spider http://localhost:$ELEMENT_WEB_PORT/config.json

View File

@@ -1,6 +1,6 @@
#!/bin/sh
# Loads modules from `/tmp/element-web-modules` into config.json's `modules` field
# Loads modules from `/modules` into config.json's `modules` field
set -e
@@ -15,15 +15,15 @@ mkdir -p /tmp/element-web-config
cp /app/config*.json /tmp/element-web-config/
# If there are modules to be loaded
if [ -d "/tmp/element-web-modules" ]; then
cd /tmp/element-web-modules
if [ -d "/modules" ]; then
cd /modules
for MODULE in *
do
# If the module has a package.json, use its main field as the entrypoint
ENTRYPOINT="index.js"
if [ -f "/tmp/element-web-modules/$MODULE/package.json" ]; then
ENTRYPOINT=$(jq -r '.main' "/tmp/element-web-modules/$MODULE/package.json")
if [ -f "/modules/$MODULE/package.json" ]; then
ENTRYPOINT=$(jq -r '.main' "/modules/$MODULE/package.json")
fi
entrypoint_log "Loading module $MODULE with entrypoint $ENTRYPOINT"

View File

@@ -22,7 +22,7 @@ server {
add_header Cache-Control "no-cache";
}
location /modules {
alias /tmp/element-web-modules;
alias /modules;
}
# redirect server error pages to the static page /50x.html
#

View File

@@ -67,7 +67,7 @@ image as root (`docker run --user 0`) or, better, change the port that nginx
listens on via the `ELEMENT_WEB_PORT` environment variable.
[Element Web Modules](https://github.com/element-hq/element-modules/tree/main/packages/element-web-module-api) can be dynamically loaded
by being made available (e.g. via bind mount) in a directory within `/tmp/element-web-modules/`.
by being made available (e.g. via bind mount) in a directory within `/modules/`.
The default entrypoint will be index.js in that directory but can be overridden if a package.json file is found with a `main` directive.
These modules will be presented in a `/modules` subdirectory within the webroot, and automatically added to the config.json `modules` field.
@@ -75,7 +75,7 @@ If you wish to use docker in read-only mode,
you should follow the [upstream instructions](https://hub.docker.com/_/nginx#:~:text=Running%20nginx%20in%20read%2Donly%20mode)
but additionally include the following directories:
- /tmp/element-web-config/
- /tmp/
- /etc/nginx/conf.d/
The behaviour of the docker image can be customised via the following

View File

@@ -1,6 +1,6 @@
{
"name": "element-web",
"version": "1.11.94",
"version": "1.11.95",
"description": "Element: the future of secure communication",
"author": "New Vector Ltd.",
"repository": {
@@ -62,19 +62,19 @@
"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 --grep @screenshot --project=Chrome",
"test:playwright:screenshots": "playwright-screenshots --project=Chrome",
"coverage": "yarn test --coverage",
"analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp",
"update:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js"
},
"resolutions": {
"@playwright/test": "1.50.1",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"oidc-client-ts": "3.1.0",
"jwt-decode": "4.0.0",
"caniuse-lite": "1.0.30001701",
"testcontainers": "10.20.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
"wrap-ansi": "npm:wrap-ansi@^7.0.0"
},
@@ -84,7 +84,7 @@
"@fontsource/inconsolata": "^5",
"@fontsource/inter": "^5",
"@formatjs/intl-segmenter": "^11.5.7",
"@matrix-org/analytics-events": "^0.29.0",
"@matrix-org/analytics-events": "^0.29.2",
"@matrix-org/emojibase-bindings": "^1.3.4",
"@matrix-org/react-sdk-module-api": "^2.4.0",
"@matrix-org/spec": "^1.7.0",
@@ -92,7 +92,7 @@
"@types/png-chunks-extract": "^1.0.2",
"@types/react-virtualized": "^9.21.30",
"@vector-im/compound-design-tokens": "^4.0.0",
"@vector-im/compound-web": "^7.6.4",
"@vector-im/compound-web": "^7.7.2",
"@vector-im/matrix-wysiwyg": "2.38.2",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
@@ -158,7 +158,6 @@
"devDependencies": {
"@action-validator/cli": "^0.6.0",
"@action-validator/core": "^0.6.0",
"@axe-core/playwright": "^4.8.1",
"@babel/core": "^7.12.10",
"@babel/eslint-parser": "^7.12.10",
"@babel/eslint-plugin": "^7.12.10",
@@ -178,13 +177,13 @@
"@babel/preset-typescript": "^7.12.7",
"@babel/runtime": "^7.12.5",
"@casualbot/jest-sonar-reporter": "2.2.7",
"@element-hq/element-web-playwright-common": "^1.1.5",
"@peculiar/webcrypto": "^1.4.3",
"@playwright/test": "^1.40.1",
"@playwright/test": "^1.50.1",
"@principalstudio/html-webpack-inject-preload": "^1.2.7",
"@sentry/webpack-plugin": "^3.0.0",
"@stylistic/eslint-plugin": "^3.0.0",
"@svgr/webpack": "^8.0.0",
"@testcontainers/postgresql": "^10.16.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
@@ -259,13 +258,12 @@
"jsqr": "^1.4.0",
"knip": "^5.36.2",
"lint-staged": "^15.0.2",
"mailpit-api": "^1.0.5",
"matrix-web-i18n": "^3.2.1",
"mini-css-extract-plugin": "2.9.2",
"minimist": "^1.2.6",
"modernizr": "^3.12.0",
"node-fetch": "^2.6.7",
"playwright-core": "^1.45.1",
"playwright-core": "^1.51.0",
"postcss": "8.4.46",
"postcss-easings": "^4.0.0",
"postcss-hexrgba": "2.1.0",
@@ -282,13 +280,12 @@
"rimraf": "^6.0.0",
"semver": "^7.5.2",
"source-map-loader": "^5.0.0",
"strip-ansi": "^7.1.0",
"stylelint": "^16.13.0",
"stylelint-config-standard": "^37.0.0",
"stylelint-scss": "^6.0.0",
"stylelint-value-no-unknown-custom-properties": "^6.0.1",
"terser-webpack-plugin": "^5.3.9",
"testcontainers": "^10.16.0",
"testcontainers": "^10.20.0",
"ts-node": "^10.9.1",
"typescript": "5.8.2",
"util": "^0.12.5",

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import { defineConfig, devices } from "@playwright/test";
import { Options } from "./playwright/services";
import { type WorkerOptions } from "./playwright/services";
const baseURL = process.env["BASE_URL"] ?? "http://localhost:8080";
@@ -21,7 +21,7 @@ const chromeProject = {
},
};
export default defineConfig<Options>({
export default defineConfig<WorkerOptions>({
projects: [
{
name: "Chrome",
@@ -83,6 +83,7 @@ export default defineConfig<Options>({
url: `${baseURL}/config.json`,
reuseExistingServer: true,
timeout: (process.env.CI ? 30 : 120) * 1000,
stdout: "pipe",
},
testDir: "playwright/e2e",
outputDir: "playwright/test-results",

View File

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

View File

@@ -1,9 +0,0 @@
FROM mcr.microsoft.com/playwright:v1.50.1-noble
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

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

View File

@@ -5,11 +5,11 @@ 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 APIRequestContext } from "playwright-core";
import { type APIRequestContext } from "@playwright/test";
import { type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import { ClientServerApi } from "@element-hq/element-web-playwright-common/lib/utils/api.js";
import { type HomeserverInstance } from "../plugins/homeserver";
import { ClientServerApi } from "../plugins/utils/api.ts";
/**
* A small subset of the Client-Server API used to manipulate the state of the

View File

@@ -267,7 +267,6 @@ test.describe("Editing", () => {
app,
room,
axe,
checkA11y,
}) => {
axe.disableRules("color-contrast"); // XXX: We have some known contrast issues here
@@ -282,7 +281,7 @@ test.describe("Editing", () => {
const line = tile.locator(".mx_EventTile_line");
await line.hover();
await line.getByRole("button", { name: "Edit" }).click();
await checkA11y();
await expect(axe).toHaveNoViolations();
const editComposer = page.getByRole("textbox", { name: "Edit message" });
await editComposer.pressSequentially("Foo");
await editComposer.press("Backspace");
@@ -290,7 +289,7 @@ test.describe("Editing", () => {
await editComposer.press("Backspace");
await editComposer.press("Enter");
await app.getComposerField().hover(); // XXX: move the hover to get rid of the "Edit" tooltip
await checkA11y();
await expect(axe).toHaveNoViolations();
}
await expect(
page.locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", { hasText: "Message" }),
@@ -305,7 +304,6 @@ test.describe("Editing", () => {
user,
app,
axe,
checkA11y,
bot: bob,
}) => {
// This tests the behaviour when a message has been edited some time after it has been sent, and we

View File

@@ -0,0 +1,88 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { expect, test } from "../../../element-web-test";
import type { Page } from "@playwright/test";
test.describe("Room list filters and sort", () => {
test.use({
displayName: "Alice",
botCreateOpts: {
displayName: "BotBob",
autoAcceptInvites: true,
},
labsFlags: ["feature_new_room_list"],
});
/**
* Get the room list
* @param page
*/
function getRoomList(page: Page) {
return page.getByTestId("room-list");
}
function getPrimaryFilters(page: Page) {
return page.getByRole("listbox", { name: "Room list filters" });
}
test.beforeEach(async ({ page, app, bot, user }) => {
// The notification toast is displayed above the search section
await app.closeNotificationToast();
await app.client.createRoom({ name: "empty room" });
const unReadDmId = await bot.createRoom({
name: "unread dm",
invite: [user.userId],
is_direct: true,
});
await bot.sendMessage(unReadDmId, "I am a robot. Beep.");
const unReadRoomId = await app.client.createRoom({ name: "unread room" });
await app.client.inviteUser(unReadRoomId, bot.credentials.userId);
await bot.joinRoom(unReadRoomId);
await bot.sendMessage(unReadRoomId, "I am a robot. Beep.");
const favouriteId = await app.client.createRoom({ name: "favourite room" });
await app.client.evaluate(async (client, favouriteId) => {
await client.setRoomTag(favouriteId, "m.favourite", { order: 0.5 });
}, favouriteId);
});
test("should filter the list (with primary filters)", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomList = getRoomList(page);
const primaryFilters = getPrimaryFilters(page);
const allFilters = await primaryFilters.locator("option").all();
for (const filter of allFilters) {
expect(await filter.getAttribute("aria-selected")).toBe("false");
}
await expect(primaryFilters).toMatchScreenshot("unselected-primary-filters.png");
await primaryFilters.getByRole("option", { name: "Unread" }).click();
// only one room should be visible
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(2);
await expect(primaryFilters).toMatchScreenshot("unread-primary-filters.png");
await primaryFilters.getByRole("option", { name: "Favourite" }).click();
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);
await primaryFilters.getByRole("option", { name: "People" }).click();
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);
await primaryFilters.getByRole("option", { name: "Rooms" }).click();
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "empty room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(3);
});
});

View File

@@ -11,6 +11,7 @@ import { test, expect } from "../../../element-web-test";
test.describe("Room list", () => {
test.use({
displayName: "Alice",
labsFlags: ["feature_new_room_list"],
});
@@ -47,4 +48,33 @@ test.describe("Room list", () => {
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible();
});
test("should open the more options menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomList(page);
const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" });
await roomItem.hover();
await expect(roomItem).toMatchScreenshot("room-list-item-hover.png");
const roomItemMenu = roomItem.getByRole("button", { name: "More Options" });
await roomItemMenu.click();
await expect(page).toMatchScreenshot("room-list-item-open-more-options.png");
// It should make the room favourited
await page.getByRole("menuitemcheckbox", { name: "Favourited" }).click();
// Check that the room is favourited
await roomItem.hover();
await roomItemMenu.click();
await expect(page.getByRole("menuitemcheckbox", { name: "Favourited" })).toBeChecked();
// It should show the invite dialog
await page.getByRole("menuitem", { name: "invite" }).click();
await expect(page.getByRole("heading", { name: "Invite to room29" })).toBeVisible();
await app.closeDialog();
// It should leave the room
await roomItem.hover();
await roomItemMenu.click();
await page.getByRole("menuitem", { name: "leave room" }).click();
await expect(roomItem).not.toBeVisible();
});
});

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type Page } from "playwright-core";
import { type Page } from "@playwright/test";
import { expect, test } from "../../element-web-test";
import { selectHomeserver } from "../utils";
@@ -120,7 +120,7 @@ test.describe("Login", () => {
credentials,
page,
homeserver,
checkA11y,
axe,
}) => {
await page.goto("/");
@@ -149,7 +149,7 @@ test.describe("Login", () => {
await expect(page.getByRole("textbox", { name: "Username" })).toBeVisible();
// Disabled because flaky - see https://github.com/vector-im/element-web/issues/24688
// cy.percySnapshot("Login");
await checkA11y();
await expect(axe).toHaveNoViolations();
await page.getByRole("textbox", { name: "Username" }).fill(credentials.username);
await page.getByPlaceholder("Password").fill(credentials.password);

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
/* See readme.md for tips on writing these tests. */
import { type Locator, type Page } from "playwright-core";
import { type Locator, type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type MailpitClient } from "mailpit-api";
import { type MailpitClient } from "@element-hq/element-web-playwright-common/lib/testcontainers";
import { type Page } from "@playwright/test";
import { expect } from "../../element-web-test";

View File

@@ -34,7 +34,7 @@ test.describe("Email Registration", async () => {
test(
"registers an account and lands on the home page",
{ tag: "@screenshot" },
async ({ page, mailpitClient, request, checkA11y }) => {
async ({ page, mailpitClient, request, axe }) => {
await expect(page.getByRole("textbox", { name: "Username" })).toBeVisible();
// Hide the server text as it contains the randomly allocated Homeserver port
const screenshotOptions = { mask: [page.locator(".mx_ServerPicker_server")] };
@@ -47,7 +47,7 @@ test.describe("Email Registration", async () => {
await expect(page.getByText("Check your email to continue")).toBeVisible();
await expect(page).toMatchScreenshot("registration_check_your_email.png", screenshotOptions);
await checkA11y();
await expect(axe).toHaveNoViolations();
await expect(page.getByText("An error was encountered when sending the email")).not.toBeVisible();

View File

@@ -33,12 +33,12 @@ test.describe("Registration", () => {
test(
"registers an account and lands on the home screen",
{ tag: "@screenshot" },
async ({ homeserver, page, checkA11y, crypto }) => {
async ({ homeserver, page, axe, crypto }) => {
await page.getByRole("button", { name: "Edit", exact: true }).click();
await expect(page.getByRole("button", { name: "Continue", exact: true })).toBeVisible();
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("server-picker.png");
await checkA11y();
await expect(axe).toHaveNoViolations();
await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.baseUrl);
await page.getByRole("button", { name: "Continue", exact: true }).click();
@@ -52,7 +52,7 @@ test.describe("Registration", () => {
includeDialogBackground: true,
};
await expect(page).toMatchScreenshot("registration.png", screenshotOptions);
await checkA11y();
await expect(axe).toHaveNoViolations();
await page.getByRole("textbox", { name: "Username", exact: true }).fill("alice");
await page.getByPlaceholder("Password", { exact: true }).fill("totally a great password");
@@ -62,12 +62,12 @@ test.describe("Registration", () => {
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(page).toMatchScreenshot("email-prompt.png", screenshotOptions);
await checkA11y();
await expect(axe).toHaveNoViolations();
await dialog.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page.locator(".mx_InteractiveAuthEntryComponents_termsPolicy")).toBeVisible();
await expect(page).toMatchScreenshot("terms-prompt.png", screenshotOptions);
await checkA11y();
await expect(axe).toHaveNoViolations();
const termsPolicy = page.locator(".mx_InteractiveAuthEntryComponents_termsPolicy");
await termsPolicy.getByRole("checkbox").click(); // Click the checkbox before terms of service anchor link

View File

@@ -17,9 +17,7 @@ import {
} from "../../crypto/utils";
test.describe("Encryption tab", () => {
test.use({
displayName: "Alice",
});
test.use({ displayName: "Alice" });
let recoveryKey: GeneratedSecretStorageKey;
let expectedBackupVersion: string;
@@ -111,4 +109,36 @@ test.describe("Encryption tab", () => {
// The user is prompted to reset their identity
await expect(dialog.getByText("Forgot your recovery key? Youll need to reset your identity.")).toBeVisible();
});
test("should warn before turning off key storage", { tag: "@screenshot" }, async ({ page, app, util }) => {
await verifySession(app, recoveryKey.encodedPrivateKey);
await util.openEncryptionTab();
await page.getByRole("checkbox", { name: "Allow key storage" }).click();
await expect(
page.getByRole("heading", { name: "Are you sure you want to turn off key storage and delete it?" }),
).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("delete-key-storage-confirm.png");
const deleteRequestPromises = [
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.master")),
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.self_signing")),
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.user_signing")),
page.waitForRequest((req) => req.url().endsWith("/account_data/m.megolm_backup.v1")),
page.waitForRequest((req) => req.url().endsWith("/account_data/m.secret_storage.default_key")),
page.waitForRequest((req) => req.url().includes("/account_data/m.secret_storage.key.")),
];
await page.getByRole("button", { name: "Delete key storage" }).click();
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).not.toBeChecked();
for (const prom of deleteRequestPromises) {
const request = await prom;
expect(request.method()).toBe("PUT");
expect(request.postData()).toBe(JSON.stringify({}));
}
});
});

View File

@@ -0,0 +1,18 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("Quick settings menu", () => {
test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page, user }) => {
await page.getByRole("button", { name: "Quick settings" }).click();
// Assert that the top heading is renderedc
const settings = page.getByTestId("quick-settings-menu");
await expect(settings).toBeVisible();
await expect(settings).toMatchScreenshot("quick-settings.png");
});
});

View File

@@ -227,7 +227,7 @@ test.describe("Spaces", () => {
test(
"should render subspaces in the space panel only when expanded",
{ tag: "@screenshot" },
async ({ page, app, user, axe, checkA11y }) => {
async ({ page, app, user, axe }) => {
axe.disableRules([
// Disable this check as it triggers on nested roving tab index elements which are in practice fine
"nested-interactive",
@@ -249,7 +249,7 @@ test.describe("Spaces", () => {
await expect(spaceTree.getByRole("button", { name: "Root Space" })).toBeVisible();
await expect(spaceTree.getByRole("button", { name: "Child Space" })).not.toBeVisible();
await checkA11y();
await expect(axe).toHaveNoViolations();
await expect(page.locator(".mx_SpacePanel")).toMatchScreenshot("space-panel-collapsed.png");
// This finds the expand button with the class name "mx_SpaceButton_toggleCollapse". Note there is another
@@ -261,7 +261,7 @@ test.describe("Spaces", () => {
await expect(item).toBeVisible();
await expect(item.locator(".mx_SpaceItem", { hasText: "Child Space" })).toBeVisible();
await checkA11y();
await expect(axe).toHaveNoViolations();
await expect(page.locator(".mx_SpacePanel")).toMatchScreenshot("space-panel-expanded.png");
},
);

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -277,7 +277,7 @@ test.describe("Timeline", () => {
test(
"should add inline start margin to an event line on IRC layout",
{ tag: "@screenshot" },
async ({ page, app, room, axe, checkA11y }) => {
async ({ page, app, room, axe }) => {
axe.disableRules("color-contrast");
await page.goto(`/#/room/${room.roomId}`);
@@ -318,7 +318,7 @@ test.describe("Timeline", () => {
`,
},
);
await checkA11y();
await expect(axe).toHaveNoViolations();
},
);
});
@@ -743,68 +743,64 @@ test.describe("Timeline", () => {
).toBeVisible();
});
test(
"should render url previews",
{ tag: "@screenshot" },
async ({ page, app, room, axe, checkA11y, context }) => {
axe.disableRules("color-contrast");
test("should render url previews", { tag: "@screenshot" }, async ({ page, app, room, axe, context }) => {
axe.disableRules("color-contrast");
// Element Web uses a Service Worker to rewrite unauthenticated media requests to authenticated ones, but
// the page can't see this happening. We intercept the route at the BrowserContext to ensure we get it
// post-worker, but we can't waitForResponse on that, so the page context is still used there. Because
// the page doesn't see the rewrite, it waits for the unauthenticated route. This is only confusing until
// the js-sdk (and thus the app as a whole) switches to using authenticated endpoints by default, hopefully.
await context.route(
"**/_matrix/client/v1/media/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*",
async (route) => {
await route.fulfill({
path: "playwright/sample-files/riot.png",
});
},
);
await page.route(
"**/_matrix/media/v3/preview_url?url=https%3A%2F%2Fcall.element.io%2F&ts=*",
async (route) => {
await route.fulfill({
json: {
"og:title": "Element Call",
"og:description": null,
"og:image:width": 48,
"og:image:height": 48,
"og:image": "mxc://matrix.org/2022-08-16_yaiSVSRIsNFfxDnV",
"og:image:type": "image/png",
"matrix:image:size": 2121,
},
});
},
);
// Element Web uses a Service Worker to rewrite unauthenticated media requests to authenticated ones, but
// the page can't see this happening. We intercept the route at the BrowserContext to ensure we get it
// post-worker, but we can't waitForResponse on that, so the page context is still used there. Because
// the page doesn't see the rewrite, it waits for the unauthenticated route. This is only confusing until
// the js-sdk (and thus the app as a whole) switches to using authenticated endpoints by default, hopefully.
await context.route(
"**/_matrix/client/v1/media/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*",
async (route) => {
await route.fulfill({
path: "playwright/sample-files/riot.png",
});
},
);
await page.route(
"**/_matrix/media/v3/preview_url?url=https%3A%2F%2Fcall.element.io%2F&ts=*",
async (route) => {
await route.fulfill({
json: {
"og:title": "Element Call",
"og:description": null,
"og:image:width": 48,
"og:image:height": 48,
"og:image": "mxc://matrix.org/2022-08-16_yaiSVSRIsNFfxDnV",
"og:image:type": "image/png",
"matrix:image:size": 2121,
},
});
},
);
const requestPromises: Promise<any>[] = [
page.waitForResponse("**/_matrix/media/v3/preview_url?url=https%3A%2F%2Fcall.element.io%2F&ts=*"),
// see context.route above for why we listen for the unauthenticated endpoint
page.waitForResponse("**/_matrix/media/v3/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*"),
];
const requestPromises: Promise<any>[] = [
page.waitForResponse("**/_matrix/media/v3/preview_url?url=https%3A%2F%2Fcall.element.io%2F&ts=*"),
// see context.route above for why we listen for the unauthenticated endpoint
page.waitForResponse("**/_matrix/media/v3/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*"),
];
await app.client.sendMessage(room.roomId, "https://call.element.io/");
await page.goto(`/#/room/${room.roomId}`);
await app.client.sendMessage(room.roomId, "https://call.element.io/");
await page.goto(`/#/room/${room.roomId}`);
await expect(page.locator(".mx_LinkPreviewWidget").getByText("Element Call")).toBeVisible();
await Promise.all(requestPromises);
await expect(page.locator(".mx_LinkPreviewWidget").getByText("Element Call")).toBeVisible();
await Promise.all(requestPromises);
await checkA11y();
await expect(axe).toHaveNoViolations();
await app.timeline.scrollToBottom();
await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot("url-preview.png", {
// Exclude timestamp and read marker from snapshot
mask: [page.locator(".mx_MessageTimestamp")],
css: `
await app.timeline.scrollToBottom();
await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot("url-preview.png", {
// Exclude timestamp and read marker from snapshot
mask: [page.locator(".mx_MessageTimestamp")],
css: `
.mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker {
display: none !important;
}
`,
});
},
);
});
});
test.describe("on search results panel", () => {
test(
@@ -909,6 +905,19 @@ test.describe("Timeline", () => {
mask: [page.locator(".mx_MessageTimestamp")],
});
});
test("should be able to hide an image", { tag: "@screenshot" }, async ({ page, app, room, context }) => {
await app.viewRoomById(room.roomId);
await sendImage(app.client, room.roomId, NEW_AVATAR);
await app.timeline.scrollToBottom();
const imgTile = page.locator(".mx_MImageBody").first();
await expect(imgTile).toBeVisible();
await imgTile.hover();
await page.getByRole("button", { name: "Hide" }).click();
// Check that the image is now hidden.
await expect(page.getByRole("link", { name: "Show image" })).toBeVisible();
});
});
test.describe("message sending", { tag: ["@no-firefox", "@no-webkit"] }, () => {

View File

@@ -7,18 +7,18 @@ Please see LICENSE files in the repository root for full details.
*/
import {
expect as baseExpect,
type Locator,
type Page,
type ExpectMatcherState,
type ElementHandle,
type MatcherReturnType,
type Page,
type Locator,
type PlaywrightTestArgs,
type Fixtures as _Fixtures,
} from "@playwright/test";
import { sanitizeForFilePath } from "playwright-core/lib/utils";
import AxeBuilder from "@axe-core/playwright";
import _ from "lodash";
import { extname } from "node:path";
import {
type TestFixtures as BaseTestFixtures,
expect as baseExpect,
type ToMatchScreenshotOptions,
} from "@element-hq/element-web-playwright-common";
import type { IConfigOptions } from "../src/IConfigOptions";
import { type Credentials } from "./plugins/homeserver";
@@ -27,71 +27,22 @@ import { Crypto } from "./pages/crypto";
import { Toasts } from "./pages/toasts";
import { Bot, type CreateBotOpts } from "./pages/bot";
import { Webserver } from "./plugins/webserver";
import { type Options, type Services, test as base } from "./services.ts";
import { type WorkerOptions, type Services, test as base } from "./services";
// Enable experimental service worker support
// See https://playwright.dev/docs/service-workers-experimental#how-to-enable
process.env["PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS"] = "1";
// This is deliberately quite a minimal config.json, so that we can test that the default settings actually work.
const CONFIG_JSON: Partial<IConfigOptions> = {
// The default language is set here for test consistency
setting_defaults: {
language: "en-GB",
},
// the location tests want a map style url.
map_style_url: "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx",
features: {
// We don't want to go through the feature announcement during the e2e test
feature_release_announcement: false,
},
};
declare module "@element-hq/element-web-playwright-common" {
// Improve the type for the config fixture based on the real type
export interface Config extends Omit<IConfigOptions, "default_server_config"> {}
}
export interface CredentialsWithDisplayName extends Credentials {
displayName: string;
}
export interface TestFixtures {
axe: AxeBuilder;
checkA11y: () => Promise<void>;
/**
* The contents of the config.json to send when the client requests it.
*/
config: typeof CONFIG_JSON;
/**
* The displayname to use for the user registered in {@link #credentials}.
*
* To set it, call `test.use({ displayName: "myDisplayName" })` in the test file or `describe` block.
* See {@link https://playwright.dev/docs/api/class-test#test-use}.
*/
displayName?: string;
/**
* A test fixture which registers a test user on the {@link #homeserver} and supplies the details
* of the registered user.
*/
credentials: CredentialsWithDisplayName;
/**
* The same as {@link https://playwright.dev/docs/api/class-fixtures#fixtures-page|`page`},
* but adds an initScript which will populate localStorage with the user's details from
* {@link #credentials} and {@link #homeserver}.
*
* Similar to {@link #user}, but doesn't load the app.
*/
pageWithCredentials: Page;
/**
* A (rather poorly-named) test fixture which registers a user per {@link #credentials}, stores
* the credentials into localStorage per {@link #homeserver}, and then loads the front page of the
* app.
*/
user: CredentialsWithDisplayName;
export interface TestFixtures extends BaseTestFixtures {
/**
* The same as {@link https://playwright.dev/docs/api/class-fixtures#fixtures-page|`page`},
* but wraps the returned `Page` in a class of utilities for interacting with the Element-Web UI,
@@ -105,13 +56,11 @@ export interface TestFixtures {
uut?: Locator; // Unit Under Test, useful place to refer a prepared locator
botCreateOpts: CreateBotOpts;
bot: Bot;
labsFlags: string[];
webserver: Webserver;
disablePresence: boolean;
}
type CombinedTestFixtures = PlaywrightTestArgs & TestFixtures;
export type Fixtures = _Fixtures<CombinedTestFixtures, Services & Options, CombinedTestFixtures>;
export type Fixtures = _Fixtures<CombinedTestFixtures, Services & WorkerOptions, CombinedTestFixtures>;
export const test = base.extend<TestFixtures>({
context: async ({ context }, use, testInfo) => {
// We skip tests instead of using grep-invert to still surface the counts in the html report
@@ -121,102 +70,12 @@ export const test = base.extend<TestFixtures>({
);
await use(context);
},
disablePresence: false,
config: {}, // We merge this atop the default CONFIG_JSON in the page fixture to make extending it easier
page: async ({ homeserver, context, page, config, labsFlags, disablePresence }, use) => {
await context.route(`http://localhost:8080/config.json*`, async (route) => {
const json = {
...CONFIG_JSON,
...config,
default_server_config: {
"m.homeserver": {
base_url: homeserver.baseUrl,
},
...config.default_server_config,
},
};
json["features"] = {
...json["features"],
// Enable the lab features
...labsFlags.reduce((obj, flag) => {
obj[flag] = true;
return obj;
}, {}),
};
if (disablePresence) {
json["enable_presence_by_hs_url"] = {
[homeserver.baseUrl]: false,
};
}
await route.fulfill({ json });
});
await use(page);
axe: async ({ axe }, use) => {
// Exclude floating UI for now
await use(axe.exclude("[data-floating-ui-portal]"));
},
displayName: undefined,
credentials: async ({ context, homeserver, displayName: testDisplayName }, use, testInfo) => {
const names = ["Alice", "Bob", "Charlie", "Daniel", "Eve", "Frank", "Grace", "Hannah", "Isaac", "Judy"];
const password = _.uniqueId("password_");
const displayName = testDisplayName ?? _.sample(names)!;
const credentials = await homeserver.registerUser(`user_${testInfo.testId}`, password, displayName);
console.log(`Registered test user ${credentials.userId} with displayname ${displayName}`);
await use({
...credentials,
displayName,
});
},
labsFlags: [],
pageWithCredentials: async ({ page, homeserver, credentials }, use) => {
await page.addInitScript(
({ baseUrl, credentials }) => {
// Seed the localStorage with the required credentials
window.localStorage.setItem("mx_hs_url", baseUrl);
window.localStorage.setItem("mx_user_id", credentials.userId);
window.localStorage.setItem("mx_access_token", credentials.accessToken);
window.localStorage.setItem("mx_device_id", credentials.deviceId);
window.localStorage.setItem("mx_is_guest", "false");
window.localStorage.setItem("mx_has_pickle_key", "false");
window.localStorage.setItem("mx_has_access_token", "true");
window.localStorage.setItem(
"mx_local_settings",
JSON.stringify({
// Retain any other settings which may have already been set
...JSON.parse(window.localStorage.getItem("mx_local_settings") || "{}"),
// Ensure the language is set to a consistent value
language: "en",
}),
);
},
{ baseUrl: homeserver.baseUrl, credentials },
);
await use(page);
},
user: async ({ pageWithCredentials: page, credentials }, use) => {
await page.goto("/");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
await use(credentials);
},
axe: async ({ page }, use) => {
await use(new AxeBuilder({ page }).exclude("[data-floating-ui-portal]"));
},
checkA11y: async ({ axe }, use, testInfo) =>
use(async () => {
const results = await axe.analyze();
await testInfo.attach("accessibility-scan-results", {
body: JSON.stringify(results, null, 2),
contentType: "application/json",
});
expect(results.violations).toEqual([]);
}),
app: async ({ page }, use) => {
const app = new ElementAppPage(page);
await use(app);
@@ -244,35 +103,23 @@ export const test = base.extend<TestFixtures>({
},
});
// Based on https://github.com/microsoft/playwright/blob/2b77ed4d7aafa85a600caa0b0d101b72c8437eeb/packages/playwright/src/util.ts#L206C8-L210C2
function sanitizeFilePathBeforeExtension(filePath: string): string {
const ext = extname(filePath);
const base = filePath.substring(0, filePath.length - ext.length);
return sanitizeForFilePath(base) + ext;
interface ExtendedToMatchScreenshotOptions extends ToMatchScreenshotOptions {
includeDialogBackground?: boolean;
showTooltips?: boolean;
timeout?: number;
}
export const expect = baseExpect.extend({
async toMatchScreenshot(
type Expectations = {
toMatchScreenshot: (
this: ExpectMatcherState,
receiver: Page | Locator,
name: `${string}.png`,
options?: {
mask?: Array<Locator>;
includeDialogBackground?: boolean;
showTooltips?: boolean;
timeout?: number;
css?: string;
},
) {
const testInfo = test.info();
if (!testInfo) throw new Error(`toMatchScreenshot() must be called during the test`);
if (!testInfo.tags.includes("@screenshot")) {
throw new Error("toMatchScreenshot() must be used in a test tagged with @screenshot");
}
const page = "page" in receiver ? receiver.page() : receiver;
options?: ExtendedToMatchScreenshotOptions,
) => Promise<MatcherReturnType>;
};
export const expect = baseExpect.extend<Expectations>({
async toMatchScreenshot(receiver, name, options) {
let css = `
.mx_MessagePanel_myReadMarker {
display: none !important;
@@ -322,21 +169,9 @@ export const expect = baseExpect.extend({
css += options.css;
}
// We add a custom style tag before taking screenshots
const style = (await page.addStyleTag({
content: css,
})) as ElementHandle<Element>;
const screenshotName = sanitizeFilePathBeforeExtension(name);
await baseExpect(receiver).toHaveScreenshot(screenshotName, options);
await style.evaluate((tag) => tag.remove());
testInfo.annotations.push({
// `_` prefix hides it from the HTML reporter
type: "_screenshot",
// include a path relative to `playwright/snapshots/`
description: testInfo.snapshotPath(screenshotName).split("/playwright/snapshots/", 2)[1],
await baseExpect(receiver).toMatchScreenshot(name, {
...options,
css,
});
return { pass: true, message: () => "", name: "toMatchScreenshot" };

View File

@@ -1,63 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type BrowserContext, type Page, type TestInfo } from "@playwright/test";
import { type Readable } from "stream";
import stripAnsi from "strip-ansi";
export class Logger {
private pages: Page[] = [];
private logs: Record<string, string> = {};
public getConsumer(container: string) {
this.logs[container] = "";
return (stream: Readable) => {
stream.on("data", (chunk) => {
this.logs[container] += chunk.toString();
});
stream.on("err", (chunk) => {
this.logs[container] += "ERR " + chunk.toString();
});
};
}
public async onTestStarted(context: BrowserContext) {
this.pages = [];
for (const id in this.logs) {
if (id.startsWith("page-")) {
delete this.logs[id];
} else {
this.logs[id] = "";
}
}
context.on("console", (msg) => {
const page = msg.page();
let pageIdx = this.pages.indexOf(page);
if (pageIdx === -1) {
this.pages.push(page);
pageIdx = this.pages.length - 1;
this.logs[`page-${pageIdx}`] = `Console logs for page with URL: ${page.url()}\n\n`;
}
const type = msg.type();
const text = msg.text();
this.logs[`page-${pageIdx}`] += `${type}: ${text}\n`;
});
}
public async onTestFinished(testInfo: TestInfo) {
if (testInfo.status !== "passed") {
for (const id in this.logs) {
if (!this.logs[id]) continue;
await testInfo.attach(id, {
body: stripAnsi(this.logs[id]),
contentType: "text/plain",
});
}
}
}
}

View File

@@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type Options } from "../../../services.ts";
import { type WorkerOptions } from "../../../services.ts";
export const isDendrite = ({ homeserverType }: Options): boolean => {
export const isDendrite = ({ homeserverType }: WorkerOptions): boolean => {
return homeserverType === "dendrite" || homeserverType === "pinecone";
};

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type ClientServerApi } from "../utils/api.ts";
import { type ClientServerApi } from "@element-hq/element-web-playwright-common/lib/utils/api.js";
export interface HomeserverInstance {
readonly baseUrl: string;

View File

@@ -6,30 +6,19 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type SynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
import { type Fixtures } from "../../../element-web-test.ts";
export const consentHomeserver: Fixtures = {
_homeserver: [
async ({ _homeserver: container, mailpit }, use) => {
container
(container as SynapseContainer)
.withCopyDirectoriesToContainer([
{ source: "playwright/plugins/homeserver/synapse/res", target: "/data/res" },
])
.withSmtpServer(mailpit)
.withConfig({
email: {
enable_notifs: false,
smtp_host: "mailpit",
smtp_port: 1025,
smtp_user: "username",
smtp_pass: "password",
require_transport_security: false,
notif_from: "Your Friendly %(app)s homeserver <noreply@example.com>",
app_name: "Matrix",
notif_template_html: "notif_mail.html",
notif_template_text: "notif_mail.txt",
notif_for_new_users: true,
client_base_url: "http://localhost/element",
},
user_consent: {
template_dir: "/data/res/templates/privacy",
version: "1.0",

View File

@@ -6,7 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { MatrixAuthenticationServiceContainer } from "../../../testcontainers/mas.ts";
import { MatrixAuthenticationServiceContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
import { type Fixtures } from "../../../element-web-test.ts";
export const masHomeserver: Fixtures = {

View File

@@ -10,8 +10,7 @@ import http from "http";
import express from "express";
import { type AddressInfo } from "net";
import { type TestInfo } from "@playwright/test";
import { randB64Bytes } from "../utils/rand.ts";
import { randB64Bytes } from "@element-hq/element-web-playwright-common/lib/utils/rand.js";
export class OAuthServer {
private server?: http.Server;

View File

@@ -1,76 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type APIRequestContext } from "@playwright/test";
import { type Credentials } from "../homeserver";
export type Verb = "GET" | "POST" | "PUT" | "DELETE";
export class Api {
private _request?: APIRequestContext;
constructor(private readonly baseUrl: string) {}
public setRequest(request: APIRequestContext): void {
this._request = request;
}
public async request<R extends {}>(verb: "GET", path: string, token?: string, data?: never): Promise<R>;
public async request<R extends {}>(verb: Verb, path: string, token?: string, data?: object): Promise<R>;
public async request<R extends {}>(verb: Verb, path: string, token?: string, data?: object): Promise<R> {
const url = `${this.baseUrl}${path}`;
const res = await this._request.fetch(url, {
data,
method: verb,
headers: token
? {
Authorization: `Bearer ${token}`,
}
: undefined,
});
if (!res.ok()) {
throw new Error(
`Request to ${url} failed with status ${res.status()}: ${JSON.stringify(await res.json())}`,
);
}
return res.json();
}
}
export class ClientServerApi extends Api {
constructor(baseUrl: string) {
super(`${baseUrl}/_matrix/client`);
}
public async loginUser(userId: string, password: string): Promise<Credentials> {
const json = await this.request<{
access_token: string;
user_id: string;
device_id: string;
home_server: string;
}>("POST", "/v3/login", undefined, {
type: "m.login.password",
identifier: {
type: "m.id.user",
user: userId,
},
password: password,
});
return {
password,
accessToken: json.access_token,
userId: json.user_id,
deviceId: json.device_id,
homeServer: json.home_server || json.user_id.split(":").slice(1).join(":"),
username: userId.slice(1).split(":")[0],
};
}
}

View File

@@ -1,16 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/**
* Deep copy the given object. The object MUST NOT have circular references and
* MUST NOT have functions.
* @param obj - The object to deep copy.
* @returns A copy of the object without any references to the original.
*/
export function deepCopy<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}

View File

@@ -1,19 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import * as net from "net";
export async function getFreePort(): Promise<number> {
return new Promise<number>((resolve) => {
const srv = net.createServer();
srv.listen(0, () => {
const port = (<net.AddressInfo>srv.address()).port;
srv.close(() => resolve(port));
});
});
}

View File

@@ -1,13 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import crypto from "node:crypto";
export function randB64Bytes(numBytes: number): string {
return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, "");
}

View File

@@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import * as http from "http";
import { type AddressInfo } from "net";
import * as http from "node:http";
import { type AddressInfo } from "node:net";
export class Webserver {
private server?: http.Server;

View File

@@ -5,113 +5,32 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { test as base } from "@playwright/test";
import { type MailpitClient } from "mailpit-api";
import { Network, type StartedNetwork } from "testcontainers";
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { test as base } from "@element-hq/element-web-playwright-common";
import {
type Services as BaseServices,
type WorkerOptions as BaseWorkerOptions,
} from "@element-hq/element-web-playwright-common/lib/fixtures";
import { type HomeserverContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
import { type SynapseConfig, SynapseContainer } from "./testcontainers/synapse.ts";
import { Logger } from "./logger.ts";
import { type StartedMatrixAuthenticationServiceContainer } from "./testcontainers/mas.ts";
import { type HomeserverContainer, type StartedHomeserverContainer } from "./testcontainers/HomeserverContainer.ts";
import { MailhogContainer, type StartedMailhogContainer } from "./testcontainers/mailpit.ts";
import { type OAuthServer } from "./plugins/oauth_server";
import { DendriteContainer, PineconeContainer } from "./testcontainers/dendrite.ts";
import { DendriteContainer, PineconeContainer } from "./testcontainers/dendrite";
import { type HomeserverType } from "./plugins/homeserver";
import { SynapseContainer } from "./testcontainers/synapse";
export interface TestFixtures {
mailpitClient: MailpitClient;
}
export interface Services {
logger: Logger;
network: StartedNetwork;
postgres: StartedPostgreSqlContainer;
mailpit: StartedMailhogContainer;
synapseConfig: SynapseConfig;
_homeserver: HomeserverContainer<any>;
homeserver: StartedHomeserverContainer;
// Set in masHomeserver only
mas?: StartedMatrixAuthenticationServiceContainer;
export interface Services extends BaseServices {
// Set in legacyOAuthHomeserver only
oAuthServer?: OAuthServer;
}
export interface Options {
export interface WorkerOptions extends BaseWorkerOptions {
homeserverType: HomeserverType;
}
export const test = base.extend<TestFixtures, Services & Options>({
logger: [
// eslint-disable-next-line no-empty-pattern
async ({}, use) => {
const logger = new Logger();
await use(logger);
},
{ scope: "worker" },
],
network: [
// eslint-disable-next-line no-empty-pattern
async ({}, use) => {
const network = await new Network().start();
await use(network);
await network.stop();
},
{ scope: "worker" },
],
postgres: [
async ({ logger, network }, use) => {
const container = await new PostgreSqlContainer()
.withNetwork(network)
.withNetworkAliases("postgres")
.withLogConsumer(logger.getConsumer("postgres"))
.withTmpFs({
"/dev/shm/pgdata/data": "",
})
.withEnvironment({
PG_DATA: "/dev/shm/pgdata/data",
})
.withCommand([
"-c",
"shared_buffers=128MB",
"-c",
`fsync=off`,
"-c",
`synchronous_commit=off`,
"-c",
"full_page_writes=off",
])
.start();
await use(container);
await container.stop();
},
{ scope: "worker" },
],
mailpit: [
async ({ logger, network }, use) => {
const container = await new MailhogContainer()
.withNetwork(network)
.withNetworkAliases("mailpit")
.withLogConsumer(logger.getConsumer("mailpit"))
.start();
await use(container);
await container.stop();
},
{ scope: "worker" },
],
mailpitClient: async ({ mailpit: container }, use) => {
await container.client.deleteMessages();
await use(container.client);
},
synapseConfig: [{}, { scope: "worker" }],
export const test = base.extend<{}, Services & WorkerOptions>({
homeserverType: ["synapse", { option: true, scope: "worker" }],
_homeserver: [
async ({ homeserverType }, use) => {
let container: HomeserverContainer<any>;
let container: HomeserverContainer<unknown>;
switch (homeserverType) {
case "synapse":
container = new SynapseContainer();
@@ -128,46 +47,12 @@ export const test = base.extend<TestFixtures, Services & Options>({
},
{ scope: "worker" },
],
homeserver: [
async ({ homeserverType, logger, network, _homeserver: homeserver, synapseConfig, mas }, use) => {
if (homeserver instanceof SynapseContainer) {
homeserver.withConfig(synapseConfig);
}
const container = await homeserver
.withNetwork(network)
.withNetworkAliases("homeserver")
.withLogConsumer(logger.getConsumer(homeserverType))
.withMatrixAuthenticationService(mas)
.start();
await use(container);
await container.stop();
},
{ scope: "worker" },
],
mas: [
// eslint-disable-next-line no-empty-pattern
async ({}, use) => {
// we stub the mas fixture to allow `homeserver` to depend on it to ensure
// when it is specified by `masHomeserver` it is started before the homeserver
await use(undefined);
},
{ scope: "worker" },
],
context: async (
{ homeserverType, synapseConfig, logger, context, request, _homeserver, homeserver },
use,
testInfo,
) => {
context: async ({ homeserverType, synapseConfig, context, _homeserver }, use, testInfo) => {
testInfo.skip(
!(_homeserver instanceof SynapseContainer) && Object.keys(synapseConfig).length > 0,
`Test specifies Synapse config options so is unsupported with ${homeserverType}`,
);
homeserver.setRequest(request);
await logger.onTestStarted(context);
await use(context);
await logger.onTestFinished(testInfo);
await homeserver.onTestFinished(testInfo);
},
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -1,24 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type AbstractStartedContainer, type GenericContainer } from "testcontainers";
import { type APIRequestContext, type TestInfo } from "@playwright/test";
import { type HomeserverInstance } from "../plugins/homeserver";
import { type StartedMatrixAuthenticationServiceContainer } from "./mas.ts";
export interface HomeserverContainer<Config> extends GenericContainer {
withConfigField(key: string, value: any): this;
withConfig(config: Partial<Config>): this;
withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this;
start(): Promise<StartedHomeserverContainer>;
}
export interface StartedHomeserverContainer extends AbstractStartedContainer, HomeserverInstance {
setRequest(request: APIRequestContext): void;
onTestFinished(testInfo: TestInfo): Promise<void>;
}

View File

@@ -8,12 +8,13 @@ Please see LICENSE files in the repository root for full details.
import { GenericContainer, Wait } from "testcontainers";
import * as YAML from "yaml";
import { set } from "lodash";
import { randB64Bytes } from "../plugins/utils/rand.ts";
import { StartedSynapseContainer } from "./synapse.ts";
import { deepCopy } from "../plugins/utils/object.ts";
import { type HomeserverContainer } from "./HomeserverContainer.ts";
import { type StartedMatrixAuthenticationServiceContainer } from "./mas.ts";
import { randB64Bytes } from "@element-hq/element-web-playwright-common/lib/utils/rand.js";
import { deepCopy } from "@element-hq/element-web-playwright-common/lib/utils/object.js";
import {
StartedSynapseContainer,
type HomeserverContainer,
type StartedMatrixAuthenticationServiceContainer,
} from "@element-hq/element-web-playwright-common/lib/testcontainers";
const DEFAULT_CONFIG = {
version: 2,
@@ -223,7 +224,7 @@ export class DendriteContainer extends GenericContainer implements HomeserverCon
.withWaitStrategy(Wait.forHttp("/_matrix/client/versions", 8008));
}
public withConfigField(key: string, value: any): this {
public withConfigField(key: string, value: unknown): this {
set(this.config, key, value);
return this;
}
@@ -236,6 +237,11 @@ export class DendriteContainer extends GenericContainer implements HomeserverCon
return this;
}
// Dendrite does not support SMTP at this time - https://github.com/element-hq/dendrite/issues/1298
public withSmtpServer(): this {
return this;
}
// Dendrite does not support MAS at this time
public withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this {
return this;

View File

@@ -1,33 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { AbstractStartedContainer, GenericContainer, type StartedTestContainer, Wait } from "testcontainers";
import { MailpitClient } from "mailpit-api";
export class MailhogContainer extends GenericContainer {
constructor() {
super("axllent/mailpit:latest");
this.withExposedPorts(8025).withWaitStrategy(Wait.forListeningPorts()).withEnvironment({
MP_SMTP_AUTH_ALLOW_INSECURE: "true",
MP_SMTP_AUTH_ACCEPT_ANY: "true",
});
}
public override async start(): Promise<StartedMailhogContainer> {
return new StartedMailhogContainer(await super.start());
}
}
export class StartedMailhogContainer extends AbstractStartedContainer {
public readonly client: MailpitClient;
constructor(container: StartedTestContainer) {
super(container);
this.client = new MailpitClient(`http://${container.getHost()}:${container.getMappedPort(8025)}`);
}
}

View File

@@ -1,346 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
AbstractStartedContainer,
GenericContainer,
type StartedTestContainer,
Wait,
type ExecResult,
} from "testcontainers";
import { type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import * as YAML from "yaml";
import { getFreePort } from "../plugins/utils/port.ts";
import { deepCopy } from "../plugins/utils/object.ts";
import { type Credentials } from "../plugins/homeserver";
const DEFAULT_CONFIG = {
http: {
listeners: [
{
name: "web",
resources: [
{ name: "discovery" },
{ name: "human" },
{ name: "oauth" },
{ name: "compat" },
{
name: "graphql",
playground: true,
},
{
name: "assets",
path: "/usr/local/share/mas-cli/assets/",
},
],
binds: [
{
address: "[::]:8080",
},
],
proxy_protocol: false,
},
{
name: "internal",
resources: [
{
name: "health",
},
],
binds: [
{
address: "[::]:8081",
},
],
proxy_protocol: false,
},
],
trusted_proxies: ["192.128.0.0/16", "172.16.0.0/12", "10.0.0.0/10", "127.0.0.1/8", "fd00::/8", "::1/128"],
public_base: "", // Needs to be set
issuer: "", // Needs to be set
},
database: {
host: "postgres",
port: 5432,
database: "postgres",
username: "postgres",
password: "p4S5w0rD",
max_connections: 10,
min_connections: 0,
connect_timeout: 30,
idle_timeout: 600,
max_lifetime: 1800,
},
telemetry: {
tracing: {
exporter: "none",
propagators: [],
},
metrics: {
exporter: "none",
},
sentry: {
dsn: null,
},
},
templates: {
path: "/usr/local/share/mas-cli/templates/",
assets_manifest: "/usr/local/share/mas-cli/manifest.json",
translations_path: "/usr/local/share/mas-cli/translations/",
},
email: {
from: '"Authentication Service" <root@localhost>',
reply_to: '"Authentication Service" <root@localhost>',
transport: "smtp",
mode: "plain",
hostname: "mailpit",
port: 1025,
username: "username",
password: "password",
},
secrets: {
encryption: "984b18e207c55ad5fbb2a49b217481a722917ee87b2308d4cf314c83fed8e3b5",
keys: [
{
kid: "YEAhzrKipJ",
key: "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAuIV+AW5vx52I4CuumgSxp6yvKfIAnRdALeZZCoFkIGxUli1B\nS79NJ3ls46oLh1pSD9RrhaMp6HTNoi4K3hnP9Q9v77pD7KwdFKG3UdG1zksIB0s/\n+/Ey/DmX4LPluwBBS7r/LkQ1jk745lENA++oiDqZf2D/uP8jCHlvaSNyVKTqi1ki\nOXPd4T4xBUjzuas9ze5jQVSYtfOidgnv1EzUipbIxgvH1jNt4raRlmP8mOq7xEnW\nR+cF5x6n/g17PdSEfrwO4kz6aKGZuMP5lVlDEEnMHKabFSQDBl7+Mpok6jXutbtA\nuiBnsKEahF9eoj4na4fpbRNPdIVyoaN5eGvm5wIDAQABAoIBAApyFCYEmHNWaa83\nCdVSOrRhRDE9r+c0r79pcNT1ajOjrk4qFa4yEC4R46YntCtfY5Hd1pBkIjU0l4d8\nz8Su9WTMEOwjQUEepS7L0NLi6kXZXYT8L40VpGs+32grBvBFHW0qEtQNrHJ36gMv\nx2rXoFTF7HaXiSJx3wvVxAbRqOE9tBXLsmNHaWaAdWQG5o77V9+zvMri3cAeEg2w\nVkKokb0dza7es7xG3tqS26k69SrwGeeuKo7qCHPH2cfyWmY5Yhv8iOoA59JzzbiK\nUdxyzCHskrPSpRKVkVVwmY3RBt282TmSRG7td7e5ESSj50P2e5BI5uu1Hp/dvU4F\nvYjV7kECgYEA6WqYoUpVsgQiqhvJwJIc/8gRm0mUy8TenI36z4Iim01Nt7fibWH7\nXnsFqLGjXtYNVWvBcCrUl9doEnRbJeG2eRGbGKYAWVrOeFvwM4fYvw9GoOiJdDj4\ncgWDe7eHbHE+UTqR7Nnr/UBfipoNWDh6X68HRBuXowh0Q6tOfxsrRFECgYEAyl/V\n4b8bFp3pKZZCb+KPSYsQf793cRmrBexPcLWcDPYbMZQADEZ/VLjbrNrpTOWxUWJT\nhr8MrWswnHO+l5AFu5CNO+QgV2dHLk+2w8qpdpFRPJCfXfo2D3wZ0c4cv3VCwv1V\n5y7f6XWVjDWZYV4wj6c3shxZJjZ+9Hbhf3/twbcCgYA6fuRRR3fCbRbi2qPtBrEN\nyO3gpMgNaQEA6vP4HPzfPrhDWmn8T5nXS61XYW03zxz4U1De81zj0K/cMBzHmZFJ\nNghQXQmpWwBzWVcREvJWr1Vb7erEnaJlsMwKrSvbGWYspSj82oAxr3hCG+lMOpsw\nb4S6pM+TpAK/EqdRY1WsgQKBgQCGoMaaTRXqL9bC0bEU2XVVCWxKb8c3uEmrwQ7/\n/fD4NmjUzI5TnDps1CVfkqoNe+hAKddDFqmKXHqUOfOaxDbsFje+lf5l5tDVoDYH\nfjTKKdYPIm7CiAeauYY7qpA5Vfq52Opixy4yEwUPp0CII67OggFtPaqY3zwJyWQt\n+57hdQKBgGCXM/KKt7ceUDcNJxSGjvu0zD9D5Sv2ihYlEBT/JLaTCCJdvzREevaJ\n1d+mpUAt0Lq6A8NWOMq8HPaxAik3rMQ0WtM5iG+XgsUqvTSb7NcshArDLuWGnW3m\nMC4rM0UBYAS4QweduUSH1imrwH/1Gu5+PxbiecceRMMggWpzu0Bq\n-----END RSA PRIVATE KEY-----\n",
},
{
kid: "8J1AxrlNZT",
key: "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIF1cjfIOEdy3BXJ72x6fKpEB8WP1ddZAUJAaqqr/6CpToAoGCCqGSM49\nAwEHoUQDQgAEfHdNuI1Yeh3/uOq2PlnW2vymloOVpwBYebbw4VVsna9xhnutIdQW\ndE8hkX8Yb0pIDasrDiwllVLzSvsWJAI0Kw==\n-----END EC PRIVATE KEY-----\n",
},
{
kid: "3BW6un1EBi",
key: "-----BEGIN EC PRIVATE KEY-----\nMIGkAgEBBDA+3ZV17r8TsiMdw1cpbTSNbyEd5SMy3VS1Mk/kz6O2Ev/3QZut8GE2\nq3eGtLBoVQigBwYFK4EEACKhZANiAASs8Wxjk/uRimRKXnPr2/wDaXkN9wMDjYQK\nmZULb+0ZP1/cXmuXuri8hUGhQvIU8KWY9PkpV+LMPEdpE54mHPKSLjq5CDXoSZ/P\n9f7cdRaOZ000KQPZfIFR9ujJTtDN7Vs=\n-----END EC PRIVATE KEY-----\n",
},
{
kid: "pkZ0pTKK0X",
key: "-----BEGIN EC PRIVATE KEY-----\nMHQCAQEEIHenfsXYPc5yzjZKUfvmydDR1YRwdsfZYvwHf/2wsYxooAcGBSuBBAAK\noUQDQgAEON1x7Vlu+nA0KvC5vYSOHhDUkfLYNZwYSLPFVT02h9E13yFFMIJegIBl\nAer+6PMZpPc8ycyeH9N+U9NAyliBhQ==\n-----END EC PRIVATE KEY-----\n",
},
],
},
passwords: {
enabled: true,
schemes: [
{
version: 1,
algorithm: "argon2id",
},
],
minimum_complexity: 0,
},
policy: {
wasm_module: "/usr/local/share/mas-cli/policy.wasm",
client_registration_entrypoint: "client_registration/violation",
register_entrypoint: "register/violation",
authorization_grant_entrypoint: "authorization_grant/violation",
password_entrypoint: "password/violation",
email_entrypoint: "email/violation",
data: {
client_registration: {
// allow non-SSL and localhost URIs
allow_insecure_uris: true,
// EW doesn't have contacts at this time
allow_missing_contacts: true,
},
},
},
upstream_oauth2: {
providers: [],
},
branding: {
service_name: null,
policy_uri: null,
tos_uri: null,
imprint: null,
logo_uri: null,
},
account: {
password_registration_enabled: true,
},
experimental: {
access_token_ttl: 300,
compat_token_ttl: 300,
},
rate_limiting: {
login: {
burst: 10,
per_second: 1,
},
registration: {
burst: 10,
per_second: 1,
},
},
};
export class MatrixAuthenticationServiceContainer extends GenericContainer {
private config: typeof DEFAULT_CONFIG;
private readonly args = ["-c", "/config/config.yaml"];
constructor(db: StartedPostgreSqlContainer) {
// We rely on `mas-cli manage add-email` which isn't in a release yet
// https://github.com/element-hq/matrix-authentication-service/pull/3235
super("ghcr.io/element-hq/matrix-authentication-service:sha-0b90c33");
this.config = deepCopy(DEFAULT_CONFIG);
this.config.database.username = db.getUsername();
this.config.database.password = db.getPassword();
this.withExposedPorts(8080, 8081)
.withWaitStrategy(Wait.forHttp("/health", 8081))
.withCommand(["server", ...this.args]);
}
public withConfig(config: object): this {
this.config = {
...this.config,
...config,
};
return this;
}
public override async start(): Promise<StartedMatrixAuthenticationServiceContainer> {
// MAS config issuer needs to know what URL it'll be accessed from, so we have to map the port manually
const port = await getFreePort();
this.config.http.public_base = `http://localhost:${port}/`;
this.config.http.issuer = `http://localhost:${port}/`;
this.withExposedPorts({
container: 8080,
host: port,
}).withCopyContentToContainer([
{
target: "/config/config.yaml",
content: YAML.stringify(this.config),
},
]);
return new StartedMatrixAuthenticationServiceContainer(
await super.start(),
`http://localhost:${port}`,
this.args,
);
}
}
export class StartedMatrixAuthenticationServiceContainer extends AbstractStartedContainer {
private adminTokenPromise?: Promise<string>;
constructor(
container: StartedTestContainer,
public readonly baseUrl: string,
private readonly args: string[],
) {
super(container);
}
public async getAdminToken(): Promise<string> {
if (this.adminTokenPromise === undefined) {
this.adminTokenPromise = this.registerUserInternal(
"admin",
"totalyinsecureadminpassword",
undefined,
true,
).then((res) => res.accessToken);
}
return this.adminTokenPromise;
}
private async manage(cmd: string, ...args: string[]): Promise<ExecResult> {
const result = await this.exec(["mas-cli", "manage", cmd, ...this.args, ...args]);
if (result.exitCode !== 0) {
throw new Error(`Failed mas-cli manage ${cmd}: ${result.output}`);
}
return result;
}
private async manageRegisterUser(
username: string,
password: string,
displayName?: string,
admin = false,
): Promise<string> {
const args: string[] = [];
if (admin) args.push("-a");
const result = await this.manage(
"register-user",
...args,
"-y",
"-p",
password,
"-d",
displayName ?? "",
username,
);
const registerLines = result.output.trim().split("\n");
const userId = registerLines
.find((line) => line.includes("Matrix ID: "))
?.split(": ")
.pop();
if (!userId) {
throw new Error(`Failed to register user: ${result.output}`);
}
return userId;
}
private async manageIssueCompatibilityToken(
username: string,
admin = false,
): Promise<{ accessToken: string; deviceId: string }> {
const args: string[] = [];
if (admin) args.push("--yes-i-want-to-grant-synapse-admin-privileges");
const result = await this.manage("issue-compatibility-token", ...args, username);
const parts = result.output.trim().split(/\s+/);
const accessToken = parts.find((part) => part.startsWith("mct_"));
const deviceId = parts.find((part) => part.startsWith("compat_session.device="))?.split("=")[1];
if (!accessToken || !deviceId) {
throw new Error(`Failed to issue compatibility token: ${result.output}`);
}
return { accessToken, deviceId };
}
private async registerUserInternal(
username: string,
password: string,
displayName?: string,
admin = false,
): Promise<Credentials> {
const userId = await this.manageRegisterUser(username, password, displayName, admin);
const { deviceId, accessToken } = await this.manageIssueCompatibilityToken(username, admin);
return {
userId,
accessToken,
deviceId,
homeServer: userId.slice(1).split(":").slice(1).join(":"),
displayName,
username,
password,
};
}
public async registerUser(username: string, password: string, displayName?: string): Promise<Credentials> {
return this.registerUserInternal(username, password, displayName, false);
}
public async setThreepid(username: string, medium: string, address: string): Promise<void> {
if (medium !== "email") {
throw new Error("Only email threepids are supported by MAS");
}
await this.manage("add-email", username, address);
}
}

View File

@@ -1,395 +1,20 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024-2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
AbstractStartedContainer,
GenericContainer,
type RestartOptions,
type StartedTestContainer,
Wait,
} from "testcontainers";
import { type APIRequestContext, type TestInfo } from "@playwright/test";
import crypto from "node:crypto";
import * as YAML from "yaml";
import { set } from "lodash";
import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
import { getFreePort } from "../plugins/utils/port.ts";
import { randB64Bytes } from "../plugins/utils/rand.ts";
import { type Credentials } from "../plugins/homeserver";
import { deepCopy } from "../plugins/utils/object.ts";
import { type HomeserverContainer, type StartedHomeserverContainer } from "./HomeserverContainer.ts";
import { type StartedMatrixAuthenticationServiceContainer } from "./mas.ts";
import { Api, ClientServerApi, type Verb } from "../plugins/utils/api.ts";
const TAG = "develop@sha256:2ea87d45fc7ff3327c671b3b4447e6b2032d4f5ca07d62d8aef0d900e105c2f4";
const TAG = "develop@sha256:12dcb34f71bb5619dba100e526a6c515fb4456d614310b5482852c7ca6f5adb9";
const DEFAULT_CONFIG = {
server_name: "localhost",
public_baseurl: "", // set by start method
pid_file: "/homeserver.pid",
web_client: false,
soft_file_limit: 0,
// Needs to be configured to log to the console like a good docker process
log_config: "/data/log.config",
listeners: [
{
// Listener is always port 8008 (configured in the container)
port: 8008,
tls: false,
bind_addresses: ["::"],
type: "http",
x_forwarded: true,
resources: [
{
names: ["client"],
compress: false,
},
],
},
],
database: {
// An sqlite in-memory database is fast & automatically wipes each time
name: "sqlite3",
args: {
database: ":memory:",
},
},
rc_messages_per_second: 10000,
rc_message_burst_count: 10000,
rc_registration: {
per_second: 10000,
burst_count: 10000,
},
rc_joins: {
local: {
per_second: 9999,
burst_count: 9999,
},
remote: {
per_second: 9999,
burst_count: 9999,
},
},
rc_joins_per_room: {
per_second: 9999,
burst_count: 9999,
},
rc_3pid_validation: {
per_second: 1000,
burst_count: 1000,
},
rc_invites: {
per_room: {
per_second: 1000,
burst_count: 1000,
},
per_user: {
per_second: 1000,
burst_count: 1000,
},
},
rc_login: {
address: {
per_second: 10000,
burst_count: 10000,
},
account: {
per_second: 10000,
burst_count: 10000,
},
failed_attempts: {
per_second: 10000,
burst_count: 10000,
},
},
media_store_path: "/tmp/media_store",
max_upload_size: "50M",
max_image_pixels: "32M",
dynamic_thumbnails: false,
enable_registration: true,
enable_registration_without_verification: true,
disable_msisdn_registration: false,
registrations_require_3pid: [],
enable_metrics: false,
report_stats: false,
// These placeholders will be replaced with values generated at start
registration_shared_secret: "secret",
macaroon_secret_key: "secret",
form_secret: "secret",
// Signing key must be here: it will be generated to this file
signing_key_path: "/data/localhost.signing.key",
trusted_key_servers: [],
password_config: {
enabled: true,
},
ui_auth: {},
background_updates: {
// Inhibit background updates as this Synapse isn't long-lived
min_batch_size: 100000,
sleep_duration_ms: 100000,
},
enable_authenticated_media: true,
email: undefined,
user_consent: undefined,
server_notices: undefined,
allow_guest_access: false,
experimental_features: {},
oidc_providers: [],
serve_server_wellknown: true,
presence: {
enabled: true,
include_offline_users_on_sync: true,
},
room_list_publication_rules: [{ action: "allow" }],
};
export type SynapseConfig = Partial<typeof DEFAULT_CONFIG>;
export class SynapseContainer extends GenericContainer implements HomeserverContainer<typeof DEFAULT_CONFIG> {
private config: typeof DEFAULT_CONFIG;
private mas?: StartedMatrixAuthenticationServiceContainer;
constructor() {
/**
* SynapseContainer which freezes the docker digest to stabilise tests,
* updated periodically by the `playwright-image-updates.yaml` workflow.
*/
export class SynapseContainer extends BaseSynapseContainer {
public constructor() {
super(`ghcr.io/element-hq/synapse:${TAG}`);
this.config = deepCopy(DEFAULT_CONFIG);
this.config.registration_shared_secret = randB64Bytes(16);
this.config.macaroon_secret_key = randB64Bytes(16);
this.config.form_secret = randB64Bytes(16);
const signingKey = randB64Bytes(32);
this.withWaitStrategy(Wait.forHttp("/health", 8008)).withCopyContentToContainer([
{ target: this.config.signing_key_path, content: `ed25519 x ${signingKey}` },
{
target: this.config.log_config,
content: YAML.stringify({
version: 1,
formatters: {
precise: {
format: "%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s",
},
},
handlers: {
console: {
class: "logging.StreamHandler",
formatter: "precise",
},
},
loggers: {
"synapse.storage.SQL": {
level: "DEBUG",
},
"twisted": {
handlers: ["console"],
propagate: false,
},
},
root: {
level: "DEBUG",
handlers: ["console"],
},
disable_existing_loggers: false,
}),
},
]);
}
public withConfigField(key: string, value: any): this {
set(this.config, key, value);
return this;
}
public withConfig(config: Partial<typeof DEFAULT_CONFIG>): this {
this.config = {
...this.config,
...config,
};
return this;
}
public withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this {
this.mas = mas;
return this;
}
public override async start(): Promise<StartedSynapseContainer> {
// Synapse config public_baseurl needs to know what URL it'll be accessed from, so we have to map the port manually
const port = await getFreePort();
this.withExposedPorts({
container: 8008,
host: port,
})
.withConfig({
public_baseurl: `http://localhost:${port}`,
})
.withCopyContentToContainer([
{
target: "/data/homeserver.yaml",
content: YAML.stringify(this.config),
},
]);
const container = await super.start();
const baseUrl = `http://localhost:${port}`;
if (this.mas) {
return new StartedSynapseWithMasContainer(
container,
baseUrl,
this.config.registration_shared_secret,
this.mas,
);
}
return new StartedSynapseContainer(container, baseUrl, this.config.registration_shared_secret);
}
}
export class StartedSynapseContainer extends AbstractStartedContainer implements StartedHomeserverContainer {
protected adminTokenPromise?: Promise<string>;
protected readonly adminApi: Api;
public readonly csApi: ClientServerApi;
constructor(
container: StartedTestContainer,
public readonly baseUrl: string,
private readonly registrationSharedSecret: string,
) {
super(container);
this.adminApi = new Api(`${this.baseUrl}/_synapse/admin`);
this.csApi = new ClientServerApi(this.baseUrl);
}
public restart(options?: Partial<RestartOptions>): Promise<void> {
this.adminTokenPromise = undefined;
return super.restart(options);
}
public setRequest(request: APIRequestContext): void {
this.csApi.setRequest(request);
this.adminApi.setRequest(request);
}
public async onTestFinished(testInfo: TestInfo): Promise<void> {
// Clean up the server to prevent rooms leaking between tests
await this.deletePublicRooms();
}
protected async deletePublicRooms(): Promise<void> {
const token = await this.getAdminToken();
// We hide the rooms from the room directory to save time between tests and for portability between homeservers
const { chunk: rooms } = await this.csApi.request<{
chunk: { room_id: string }[];
}>("GET", "/v3/publicRooms", token, {});
await Promise.all(
rooms.map((room) =>
this.csApi.request("PUT", `/v3/directory/list/room/${room.room_id}`, token, { visibility: "private" }),
),
);
}
private async registerUserInternal(
username: string,
password: string,
displayName?: string,
admin = false,
): Promise<Credentials> {
const path = "/v1/register";
const { nonce } = await this.adminApi.request<{ nonce: string }>("GET", path, undefined, {});
const mac = crypto
.createHmac("sha1", this.registrationSharedSecret)
.update(`${nonce}\0${username}\0${password}\0${admin ? "" : "not"}admin`)
.digest("hex");
const data = await this.adminApi.request<{
home_server: string;
access_token: string;
user_id: string;
device_id: string;
}>("POST", path, undefined, {
nonce,
username,
password,
mac,
admin,
displayname: displayName,
});
return {
homeServer: data.home_server || data.user_id.split(":").slice(1).join(":"),
accessToken: data.access_token,
userId: data.user_id,
deviceId: data.device_id,
password,
displayName,
username,
};
}
protected async getAdminToken(): Promise<string> {
if (this.adminTokenPromise === undefined) {
this.adminTokenPromise = this.registerUserInternal(
"admin",
"totalyinsecureadminpassword",
undefined,
true,
).then((res) => res.accessToken);
}
return this.adminTokenPromise;
}
private async adminRequest<R extends {}>(verb: "GET", path: string, data?: never): Promise<R>;
private async adminRequest<R extends {}>(verb: Verb, path: string, data?: object): Promise<R>;
private async adminRequest<R extends {}>(verb: Verb, path: string, data?: object): Promise<R> {
const adminToken = await this.getAdminToken();
return this.adminApi.request(verb, path, adminToken, data);
}
public registerUser(username: string, password: string, displayName?: string): Promise<Credentials> {
return this.registerUserInternal(username, password, displayName, false);
}
public async loginUser(userId: string, password: string): Promise<Credentials> {
return this.csApi.loginUser(userId, password);
}
public async setThreepid(userId: string, medium: string, address: string): Promise<void> {
await this.adminRequest("PUT", `/v2/users/${userId}`, {
threepids: [
{
medium,
address,
},
],
});
}
}
export class StartedSynapseWithMasContainer extends StartedSynapseContainer {
constructor(
container: StartedTestContainer,
baseUrl: string,
registrationSharedSecret: string,
private readonly mas: StartedMatrixAuthenticationServiceContainer,
) {
super(container, baseUrl, registrationSharedSecret);
}
protected async getAdminToken(): Promise<string> {
if (this.adminTokenPromise === undefined) {
this.adminTokenPromise = this.mas.getAdminToken();
}
return this.adminTokenPromise;
}
public registerUser(username: string, password: string, displayName?: string): Promise<Credentials> {
return this.mas.registerUser(username, password, displayName);
}
public async setThreepid(userId: string, medium: string, address: string): Promise<void> {
return this.mas.setThreepid(userId, medium, address);
}
}

View File

@@ -48,6 +48,7 @@
@import "./components/views/settings/devices/_FilteredDeviceListHeader.pcss";
@import "./components/views/settings/devices/_SecurityRecommendations.pcss";
@import "./components/views/settings/devices/_SelectableDeviceTile.pcss";
@import "./components/views/settings/encryption/_KeyStoragePanel.pcss";
@import "./components/views/settings/shared/_SettingsSubsection.pcss";
@import "./components/views/settings/shared/_SettingsSubsectionHeading.pcss";
@import "./components/views/spaces/_QuickThemeSwitcher.pcss";
@@ -270,9 +271,11 @@
@import "./views/right_panel/_WidgetCard.pcss";
@import "./views/room_settings/_AliasSettings.pcss";
@import "./views/rooms/RoomListPanel/_RoomList.pcss";
@import "./views/rooms/RoomListPanel/_RoomListCell.pcss";
@import "./views/rooms/RoomListPanel/_RoomListHeaderView.pcss";
@import "./views/rooms/RoomListPanel/_RoomListItemMenuView.pcss";
@import "./views/rooms/RoomListPanel/_RoomListItemView.pcss";
@import "./views/rooms/RoomListPanel/_RoomListPanel.pcss";
@import "./views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss";
@import "./views/rooms/RoomListPanel/_RoomListSearch.pcss";
@import "./views/rooms/_AppsDrawer.pcss";
@import "./views/rooms/_Autocomplete.pcss";

View File

@@ -0,0 +1,10 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.mx_KeyStoragePanel_toggleRow {
flex-direction: row;
}

View File

@@ -12,4 +12,5 @@ Please see LICENSE files in the repository root for full details.
align-items: var(--mx-flex-align, unset);
justify-content: var(--mx-flex-justify, unset);
gap: var(--mx-flex-gap, unset);
flex-wrap: var(--mx-flex-wrap, unset);
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.mx_RoomListItemMenuView {
svg {
fill: var(--cpd-color-icon-primary);
}
}

View File

@@ -6,7 +6,7 @@
*/
/**
* The RoomCell has the following structure:
* The RoomListItemView has the following structure:
* button----------------------------------------|
* | <-12px-> container--------------------------|
* | | room avatar <-12px-> content-----|
@@ -14,19 +14,20 @@
* | | | ----------| <-- border
* |---------------------------------------------|
*/
.mx_RoomListCell {
.mx_RoomListItemView {
all: unset;
&:hover {
background-color: var(--cpd-color-bg-action-secondary-hovered);
}
.mx_RoomListCell_container {
.mx_RoomListItemView_container {
padding-left: var(--cpd-space-3x);
font: var(--cpd-font-body-md-regular);
height: 100%;
.mx_RoomListCell_content {
.mx_RoomListItemView_content {
padding-right: var(--cpd-space-3x);
height: 100%;
flex: 1;
/* The border is only under the room name and the future hover menu */
@@ -42,3 +43,7 @@
}
}
}
.mx_RoomListItemView_menu_open {
background-color: var(--cpd-color-bg-action-secondary-hovered);
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.mx_RoomListPrimaryFilters {
margin: unset;
list-style-type: none;
padding: var(--cpd-space-2x) var(--cpd-space-3x);
}

View File

@@ -47,6 +47,7 @@ import { type DeepReadonly } from "./common";
import type MatrixChat from "../components/structures/MatrixChat";
import { type InitialCryptoSetupStore } from "../stores/InitialCryptoSetupStore";
import { type ModuleApiType } from "../modules/Api.ts";
import type { RoomListStoreV3Class } from "../stores/room-list-v3/RoomListStoreV3.ts";
/* eslint-disable @typescript-eslint/naming-convention */
@@ -99,6 +100,7 @@ declare global {
mxToastStore: ToastStore;
mxDeviceListener: DeviceListener;
mxRoomListStore: RoomListStore;
mxRoomListStoreV3: RoomListStoreV3Class;
mxRoomListLayoutStore: RoomListLayoutStore;
mxPlatformPeg: PlatformPeg;
mxIntegrationManagers: typeof IntegrationManagers;

View File

@@ -49,10 +49,13 @@ import { asyncSomeParallel } from "./utils/arrays.ts";
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
// Unfortunately named account data key used by Element X to indicate that the user
// has chosen to disable server side key backups. We need to set and honour this
// to prevent Element X from automatically turning key backup back on.
const BACKUP_DISABLED_ACCOUNT_DATA_KEY = "m.org.matrix.custom.backup_disabled";
/**
* Unfortunately-named account data key used by Element X to indicate that the user
* has chosen to disable server side key backups.
*
* We need to set and honour this to prevent Element X from automatically turning key backup back on.
*/
export const BACKUP_DISABLED_ACCOUNT_DATA_KEY = "m.org.matrix.custom.backup_disabled";
const logger = baseLogger.getChild("DeviceListener:");

View File

@@ -521,8 +521,7 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
[KeyBindingAction.GoToHome]: {
default: {
ctrlKey: true,
altKey: !IS_MAC,
shiftKey: IS_MAC,
altKey: true,
key: Key.H,
},
displayName: _td("keyboard|go_home_view"),
@@ -585,7 +584,7 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
},
[KeyBindingAction.ToggleHiddenEventVisibility]: {
default: {
ctrlOrCmdKey: true,
ctrlKey: true,
shiftKey: true,
key: Key.H,
},

View File

@@ -39,6 +39,11 @@ type FlexProps<T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any
* @default start
*/
justify?: "start" | "center" | "end" | "space-between";
/**
* The wrapping of the flex children
* @default nowrap
*/
wrap?: "wrap" | "nowrap" | "wrap-reverse";
/**
* The spacing between the flex children, expressed with the CSS unit
* @default 0
@@ -60,6 +65,7 @@ export function Flex<T extends keyof JSX.IntrinsicElements | JSXElementConstruct
align = "start",
justify = "start",
gap = "0",
wrap = "nowrap",
className,
children,
...props
@@ -71,8 +77,9 @@ export function Flex<T extends keyof JSX.IntrinsicElements | JSXElementConstruct
"--mx-flex-align": align,
"--mx-flex-justify": justify,
"--mx-flex-gap": gap,
"--mx-flex-wrap": wrap,
}),
[align, direction, display, gap, justify],
[align, direction, display, gap, justify, wrap],
);
return React.createElement(as, { ...props, className: classNames("mx_Flex", className), style }, children);

View File

@@ -6,7 +6,7 @@
*/
import { useCallback } from "react";
import { JoinRule, type Room, RoomEvent, RoomType } from "matrix-js-sdk/src/matrix";
import { EventTimeline, EventType, JoinRule, type Room, RoomEvent, RoomType } from "matrix-js-sdk/src/matrix";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
@@ -126,11 +126,19 @@ export interface RoomListHeaderViewState {
export function useRoomListHeaderViewModel(): RoomListHeaderViewState {
const matrixClient = useMatrixClientContext();
const { activeSpace, title } = useSpace();
const isSpaceRoom = Boolean(activeSpace);
const canCreateRoom = shouldShowComponent(UIComponent.CreateRooms);
const canCreateRoomInSpace = Boolean(
activeSpace
?.getLiveTimeline()
.getState(EventTimeline.FORWARDS)
?.maySendStateEvent(EventType.RoomAvatar, matrixClient.getSafeUserId()),
);
// If we are in a space, we check canCreateRoomInSpace
const canCreateRoom = shouldShowComponent(UIComponent.CreateRooms) && (!isSpaceRoom || canCreateRoomInSpace);
const canCreateVideoRoom = useFeatureEnabled("feature_video_rooms");
const displayComposeMenu = canCreateRoom;
const displaySpaceMenu = Boolean(activeSpace);
const displayComposeMenu = canCreateRoom || canCreateVideoRoom;
const displaySpaceMenu = isSpaceRoom;
const canInviteInSpace = Boolean(
activeSpace?.getJoinRule() === JoinRule.Public || activeSpace?.canInvite(matrixClient.getSafeUserId()),
);

View File

@@ -0,0 +1,180 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { useCallback } from "react";
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import { useUnreadNotifications } from "../../../hooks/useUnreadNotifications";
import { hasAccessToOptionsMenu } from "./utils";
import DMRoomMap from "../../../utils/DMRoomMap";
import { DefaultTagID } from "../../../stores/room-list/models";
import { NotificationLevel } from "../../../stores/notifications/NotificationLevel";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import dispatcher from "../../../dispatcher/dispatcher";
import { clearRoomNotification, setMarkedUnreadState } from "../../../utils/notifications";
import PosthogTrackers from "../../../PosthogTrackers";
import { tagRoom } from "../../../utils/room/tagRoom";
export interface RoomListItemMenuViewState {
/**
* Whether the more options menu should be shown.
*/
showMoreOptionsMenu: boolean;
/**
* Whether the room is a favourite room.
*/
isFavourite: boolean;
/**
* Can invite other user's in the room.
*/
canInvite: boolean;
/**
* Can copy the room link.
*/
canCopyRoomLink: boolean;
/**
* Can mark the room as read.
*/
canMarkAsRead: boolean;
/**
* Can mark the room as unread.
*/
canMarkAsUnread: boolean;
/**
* Mark the room as read.
* @param evt
*/
markAsRead: (evt: Event) => void;
/**
* Mark the room as unread.
* @param evt
*/
markAsUnread: (evt: Event) => void;
/**
* Toggle the room as favourite.
* @param evt
*/
toggleFavorite: (evt: Event) => void;
/**
* Toggle the room as low priority.
*/
toggleLowPriority: () => void;
/**
* Invite other users in the room.
* @param evt
*/
invite: (evt: Event) => void;
/**
* Copy the room link in the clipboard.
* @param evt
*/
copyRoomLink: (evt: Event) => void;
/**
* Leave the room.
* @param evt
*/
leaveRoom: (evt: Event) => void;
}
export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewState {
const matrixClient = useMatrixClientContext();
const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags);
const { level: notificationLevel } = useUnreadNotifications(room);
const showMoreOptionsMenu = hasAccessToOptionsMenu(room);
const isDm = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId));
const isFavourite = Boolean(roomTags[DefaultTagID.Favourite]);
const isArchived = Boolean(roomTags[DefaultTagID.Archived]);
const canMarkAsRead = notificationLevel > NotificationLevel.None;
const canMarkAsUnread = !canMarkAsRead && !isArchived;
const canInvite =
room.canInvite(matrixClient.getUserId()!) && !isDm && shouldShowComponent(UIComponent.InviteUsers);
const canCopyRoomLink = !isDm;
// Actions
const markAsRead = useCallback(
async (evt: Event): Promise<void> => {
await clearRoomNotification(room, matrixClient);
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkRead", evt);
},
[room, matrixClient],
);
const markAsUnread = useCallback(
async (evt: Event): Promise<void> => {
await setMarkedUnreadState(room, matrixClient, true);
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkUnread", evt);
},
[room, matrixClient],
);
const toggleFavorite = useCallback(
(evt: Event): void => {
tagRoom(room, DefaultTagID.Favourite);
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle", evt);
},
[room],
);
const toggleLowPriority = useCallback((): void => tagRoom(room, DefaultTagID.LowPriority), [room]);
const invite = useCallback(
(evt: Event): void => {
dispatcher.dispatch({
action: "view_invite",
roomId: room.roomId,
});
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuInviteItem", evt);
},
[room],
);
const copyRoomLink = useCallback(
(evt: Event): void => {
dispatcher.dispatch({
action: "copy_room",
room_id: room.roomId,
});
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle", evt);
},
[room],
);
const leaveRoom = useCallback(
(evt: Event): void => {
dispatcher.dispatch({
action: isArchived ? "forget_room" : "leave_room",
room_id: room.roomId,
});
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuLeaveItem", evt);
},
[room, isArchived],
);
return {
showMoreOptionsMenu,
isFavourite,
canInvite,
canCopyRoomLink,
canMarkAsRead,
canMarkAsUnread,
markAsRead,
markAsUnread,
toggleFavorite,
toggleLowPriority,
invite,
copyRoomLink,
leaveRoom,
};
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { useCallback } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import dispatcher from "../../../dispatcher/dispatcher";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../../../dispatcher/actions";
import { hasAccessToOptionsMenu } from "./utils";
export interface RoomListItemViewState {
/**
* Whether the hover menu should be shown.
*/
showHoverMenu: boolean;
/**
* Open the room having given roomId.
*/
openRoom: () => void;
}
/**
* View model for the room list item
* @see {@link RoomListItemViewState} for more information about what this view model returns.
*/
export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
// incoming: Check notification menu rights
const showHoverMenu = hasAccessToOptionsMenu(room);
// Actions
const openRoom = useCallback((): void => {
dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: "RoomList",
});
}, [room]);
return {
showHoverMenu,
openRoom,
};
}

View File

@@ -5,26 +5,40 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { useCallback, useState } from "react";
import type { Room } from "matrix-js-sdk/src/matrix";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
import { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
import dispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { type PrimaryFilter, type SecondaryFilters, useFilteredRooms } from "./useFilteredRooms";
import { type SortOption, useSorter } from "./useSorter";
export interface RoomListViewState {
/**
* A list of rooms to be displayed in the left panel.
*/
rooms: Room[];
/**
* A list of objects that provide the view enough information
* to render primary room filters.
*/
primaryFilters: PrimaryFilter[];
/**
* Open the room having given roomId.
* A function to activate a given secondary filter.
*/
openRoom: (roomId: string) => void;
activateSecondaryFilter: (filter: SecondaryFilters) => void;
/**
* The currently active secondary filter.
*/
activeSecondaryFilter: SecondaryFilters;
/**
* Change the sort order of the room-list.
*/
sort: (option: SortOption) => void;
/**
* The currently active sort option.
*/
activeSortOption: SortOption;
}
/**
@@ -32,20 +46,15 @@ export interface RoomListViewState {
* @see {@link RoomListViewState} for more information about what this view model returns.
*/
export function useRoomListViewModel(): RoomListViewState {
const [rooms, setRooms] = useState(RoomListStoreV3.instance.getSortedRoomsInActiveSpace());
const { primaryFilters, rooms, activateSecondaryFilter, activeSecondaryFilter } = useFilteredRooms();
const { activeSortOption, sort } = useSorter();
useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => {
const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace();
setRooms(newRooms);
});
const openRoom = useCallback((roomId: string): void => {
dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: roomId,
metricsTrigger: "RoomList",
});
}, []);
return { rooms, openRoom };
return {
rooms,
primaryFilters,
activateSecondaryFilter,
activeSecondaryFilter,
activeSortOption,
sort,
};
}

View File

@@ -0,0 +1,188 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { useCallback, useMemo, useState } from "react";
import type { Room } from "matrix-js-sdk/src/matrix";
import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters";
import { _t, _td, type TranslationKey } from "../../../languageHandler";
import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3";
import { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
/**
* Provides information about a primary filter.
* A primary filter is a commonly used filter that is given
* more precedence in the UI. For eg, primary filters may be
* rendered as pills above the room list.
*/
export interface PrimaryFilter {
// A function to toggle this filter on and off.
toggle: () => void;
// Whether this filter is currently applied
active: boolean;
// Text that can be used in the UI to represent this filter.
name: string;
}
interface FilteredRooms {
primaryFilters: PrimaryFilter[];
rooms: Room[];
activateSecondaryFilter: (filter: SecondaryFilters) => void;
activeSecondaryFilter: SecondaryFilters;
}
const filterKeyToNameMap: Map<FilterKey, TranslationKey> = new Map([
[FilterKey.UnreadFilter, _td("room_list|filters|unread")],
[FilterKey.FavouriteFilter, _td("room_list|filters|favourite")],
[FilterKey.PeopleFilter, _td("room_list|filters|people")],
[FilterKey.RoomsFilter, _td("room_list|filters|rooms")],
]);
/**
* These are the secondary filters which are not prominently shown
* in the UI.
*/
export const enum SecondaryFilters {
AllActivity,
MentionsOnly,
InvitesOnly,
LowPriority,
}
/**
* A map from {@link SecondaryFilters} which the UI understands to
* {@link FilterKey} which the store understands.
*/
const secondaryFiltersToFilterKeyMap = new Map([
[SecondaryFilters.AllActivity, undefined],
[SecondaryFilters.MentionsOnly, FilterKey.MentionsFilter],
[SecondaryFilters.InvitesOnly, FilterKey.InvitesFilter],
[SecondaryFilters.LowPriority, FilterKey.LowPriorityFilter],
]);
/**
* Use this function to determine if a given primary filter is compatible with
* a given secondary filter. Practically, this determines whether it makes sense
* to expose two filters together in the UI - for eg, it does not make sense to show the
* favourite primary filter if the active secondary filter is low priority.
* @param primary Primary filter key
* @param secondary Secondary filter key
* @returns true if compatible, false otherwise
*/
function isPrimaryFilterCompatible(primary: FilterKey, secondary: FilterKey): boolean {
if (secondary === FilterKey.MentionsFilter) {
if (primary === FilterKey.UnreadFilter) return false;
} else if (secondary === FilterKey.InvitesFilter) {
if (primary === FilterKey.UnreadFilter || primary === FilterKey.FavouriteFilter) return false;
} else if (secondary === FilterKey.LowPriorityFilter) {
if (primary === FilterKey.FavouriteFilter) return false;
}
return true;
}
/**
* Track available filters and provide a filtered list of rooms.
*/
export function useFilteredRooms(): FilteredRooms {
/**
* Primary filter refers to the pill based filters
* rendered above the room list.
*/
const [primaryFilter, setPrimaryFilter] = useState<FilterKey | undefined>();
/**
* Secondary filters are also filters but they are hidden
* away in a popup menu.
*/
const [activeSecondaryFilter, setActiveSecondaryFilter] = useState<SecondaryFilters>(SecondaryFilters.AllActivity);
const secondaryFilter = useMemo(
() => secondaryFiltersToFilterKeyMap.get(activeSecondaryFilter),
[activeSecondaryFilter],
);
const [rooms, setRooms] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace());
const updateRoomsFromStore = useCallback((filters: FilterKey[] = []): void => {
const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filters);
setRooms(newRooms);
}, []);
const filterUndefined = (array: (FilterKey | undefined)[]): FilterKey[] =>
array.filter((f) => f !== undefined) as FilterKey[];
const getAppliedFilters = (): FilterKey[] => {
return filterUndefined([primaryFilter, secondaryFilter]);
};
useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => {
const filters = getAppliedFilters();
updateRoomsFromStore(filters);
});
/**
* Secondary filters are activated using this function.
* This is different to how primary filters work because the secondary
* filters are static i.e they are always available and don't need to be
* hidden.
*/
const activateSecondaryFilter = useCallback(
(filter: SecondaryFilters): void => {
// If the filter is already active, just return.
if (filter === activeSecondaryFilter) return;
// SecondaryFilter is an enum for the UI, let's convert it to something
// that the store will understand.
const secondary = secondaryFiltersToFilterKeyMap.get(filter);
// Active primary filter may need to be toggled off when applying this secondary filer.
let primary = primaryFilter;
if (
primaryFilter !== undefined &&
secondary !== undefined &&
!isPrimaryFilterCompatible(primaryFilter, secondary)
) {
primary = undefined;
}
setActiveSecondaryFilter(filter);
setPrimaryFilter(primary);
updateRoomsFromStore(filterUndefined([primary, secondary]));
},
[activeSecondaryFilter, primaryFilter, updateRoomsFromStore],
);
/**
* This tells the view which primary filters are available, how to toggle them
* and whether a given primary filter is active. @see {@link PrimaryFilter}
*/
const primaryFilters = useMemo(() => {
const createPrimaryFilter = (key: FilterKey, name: string): PrimaryFilter => {
return {
toggle: () => {
setPrimaryFilter((currentFilter) => {
const filter = currentFilter === key ? undefined : key;
updateRoomsFromStore(filterUndefined([filter, secondaryFilter]));
return filter;
});
},
active: primaryFilter === key,
name,
};
};
const filters: PrimaryFilter[] = [];
for (const [key, name] of filterKeyToNameMap.entries()) {
if (secondaryFilter && !isPrimaryFilterCompatible(key, secondaryFilter)) {
continue;
}
filters.push(createPrimaryFilter(key, _t(name)));
}
return filters;
}, [primaryFilter, updateRoomsFromStore, secondaryFilter]);
return { primaryFilters, rooms, activateSecondaryFilter, activeSecondaryFilter };
}

View File

@@ -0,0 +1,62 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { useState } from "react";
import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3";
import { SortingAlgorithm } from "../../../stores/room-list-v3/skip-list/sorters";
import SettingsStore from "../../../settings/SettingsStore";
/**
* Sorting options made available to the view.
*/
export const enum SortOption {
Activity = SortingAlgorithm.Recency,
AToZ = SortingAlgorithm.Alphabetic,
}
/**
* {@link SortOption} holds almost the same information as
* {@link SortingAlgorithm}. This is done intentionally to
* prevent the view from having a dependence on the
* model (which is the store in this case).
*/
const sortingAlgorithmToSortingOption = {
[SortingAlgorithm.Alphabetic]: SortOption.AToZ,
[SortingAlgorithm.Recency]: SortOption.Activity,
};
const sortOptionToSortingAlgorithm = {
[SortOption.AToZ]: SortingAlgorithm.Alphabetic,
[SortOption.Activity]: SortingAlgorithm.Recency,
};
interface SortState {
sort: (option: SortOption) => void;
activeSortOption: SortOption;
}
/**
* This hook does two things:
* - Provides a way to track the currently active sort option.
* - Provides a function to resort the room list.
*/
export function useSorter(): SortState {
const [activeSortingAlgorithm, setActiveSortingAlgorithm] = useState(() =>
SettingsStore.getValue("RoomList.preferredSorting"),
);
const sort = (option: SortOption): void => {
const sortingAlgorithm = sortOptionToSortingAlgorithm[option];
RoomListStoreV3.instance.resort(sortingAlgorithm);
setActiveSortingAlgorithm(sortingAlgorithm);
};
return {
sort,
activeSortOption: sortingAlgorithmToSortingOption[activeSortingAlgorithm!],
};
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type Room, KnownMembership } from "matrix-js-sdk/src/matrix";
import { isKnockDenied } from "../../../utils/membership";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
/**
* Check if the user has access to the options menu.
* @param room
*/
export function hasAccessToOptionsMenu(room: Room): boolean {
return (
room.getMyMembership() === KnownMembership.Invite ||
(room.getMyMembership() !== KnownMembership.Knock &&
!isKnockDenied(room) &&
shouldShowComponent(UIComponent.RoomOptionsMenu))
);
}

View File

@@ -0,0 +1,116 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { useCallback, useEffect, useState } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import DeviceListener, { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../../../DeviceListener";
interface KeyStoragePanelState {
/**
* Whether the app's "key storage" option should show as enabled to the user,
* or 'undefined' if the state is still loading.
*/
isEnabled: boolean | undefined;
/**
* A function that can be called to enable or disable key storage.
* @param enable True to turn key storage on or false to turn it off
*/
setEnabled: (enable: boolean) => void;
/**
* True if the state is still loading for the first time
*/
loading: boolean;
/**
* True if the status is in the process of being changed
*/
busy: boolean;
}
/** Returns a ViewModel for use in {@link KeyStoragePanel} and {@link DeleteKeyStoragePanel}. */
export function useKeyStoragePanelViewModel(): KeyStoragePanelState {
const [isEnabled, setIsEnabled] = useState<boolean | undefined>(undefined);
const [loading, setLoading] = useState(true);
// Whilst the change is being made, the toggle will reflect the pending value rather than the actual state
const [pendingValue, setPendingValue] = useState<boolean | undefined>(undefined);
const matrixClient = useMatrixClientContext();
const checkStatus = useCallback(async () => {
const crypto = matrixClient.getCrypto();
if (!crypto) {
logger.error("Can't check key backup status: no crypto module available");
return;
}
// The toggle is enabled only if this device will upload megolm keys to the backup.
// This is consistent with EX.
const activeBackupVersion = await crypto.getActiveSessionBackupVersion();
setIsEnabled(activeBackupVersion !== null);
}, [matrixClient]);
useEffect(() => {
(async () => {
await checkStatus();
setLoading(false);
})();
}, [checkStatus]);
const setEnabled = useCallback(
async (enable: boolean) => {
setPendingValue(enable);
try {
// stop the device listener since enabling or (especially) disabling key storage must be
// done with a sequence of API calls that will put the account in a slightly different
// state each time, so suppress any warning toasts until the process is finished (when
// we'll turn it back on again.)
DeviceListener.sharedInstance().stop();
const crypto = matrixClient.getCrypto();
if (!crypto) {
logger.error("Can't change key backup status: no crypto module available");
return;
}
if (enable) {
// If there is no existing key backup on the server, create one.
// `resetKeyBackup` will delete any existing backup, so we only do this if there is no existing backup.
const currentKeyBackup = await crypto.checkKeyBackupAndEnable();
if (currentKeyBackup === null) {
await crypto.resetKeyBackup();
// resetKeyBackup fires this off in the background without waiting, so we need to do it
// explicitly and wait for it, otherwise it won't be enabled yet when we check again.
await crypto.checkKeyBackupAndEnable();
}
// Set the flag so that EX no longer thinks the user wants backup disabled
await matrixClient.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: false });
} else {
// This method will delete the key backup as well as server side recovery keys and other
// server-side crypto data.
await crypto.disableKeyStorage();
// Set a flag to say that the user doesn't want key backup.
// Element X uses this to determine whether to set up automatically,
// so this will stop EX turning it back on spontaneously.
await matrixClient.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true });
}
await checkStatus();
} finally {
setPendingValue(undefined);
DeviceListener.sharedInstance().start(matrixClient);
}
},
[setPendingValue, checkStatus, matrixClient],
);
return { isEnabled: pendingValue ?? isEnabled, setEnabled, loading, busy: pendingValue !== undefined };
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -7,7 +7,14 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { type ComponentProps } from "react";
import { type Room, RoomStateEvent, type MatrixEvent, EventType, RoomType } from "matrix-js-sdk/src/matrix";
import {
type Room,
RoomStateEvent,
type MatrixEvent,
EventType,
RoomType,
KnownMembership,
} from "matrix-js-sdk/src/matrix";
import BaseAvatar from "./BaseAvatar";
import ImageView from "../elements/ImageView";
@@ -19,6 +26,7 @@ import { mediaFromMxc } from "../../../customisations/Media";
import { type IOOBData } from "../../../stores/ThreepidInviteStore";
import { LocalRoom } from "../../../models/LocalRoom";
import { filterBoolean } from "../../../utils/arrays";
import SettingsStore from "../../../settings/SettingsStore";
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick"> {
// Room may be left unset here, but if it is,
@@ -86,6 +94,13 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
};
private static getImageUrls(props: IProps): string[] {
const myMembership = props.room?.getMyMembership();
if (myMembership === KnownMembership.Invite || !myMembership) {
if (SettingsStore.getValue("showAvatarsOnInvites") === false) {
// The user has opted out of showing avatars, so return no urls here.
return [];
}
}
let oobAvatar: string | null = null;
if (props.oobData.avatarUrl) {
oobAvatar = mediaFromMxc(props.oobData.avatarUrl).getThumbnailOfSourceHttp(

View File

@@ -191,6 +191,7 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
_td("settings|encryption|title"),
<KeyIcon />,
<EncryptionUserSettingsTab initialState={showResetIdentity ? "reset_identity_forgot" : undefined} />,
"UserSettingsEncryption",
),
);

View File

@@ -0,0 +1,44 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import React from "react";
import { VisibilityOffIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
import { _t } from "../../../languageHandler";
import { useMediaVisible } from "../../../hooks/useMediaVisible";
interface IProps {
/**
* Matrix event that this action applies to.
*/
mxEvent: MatrixEvent;
}
/**
* Quick action button for marking a media event as hidden.
*/
export const HideActionButton: React.FC<IProps> = ({ mxEvent }) => {
const [mediaIsVisible, setVisible] = useMediaVisible(mxEvent.getId()!);
if (!mediaIsVisible) {
return;
}
return (
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton "
title={_t("action|hide")}
onClick={() => setVisible(false)}
placement="left"
>
<VisibilityOffIcon />
</RovingAccessibleButton>
);
};

View File

@@ -93,7 +93,7 @@ export function computedStyle(element: HTMLElement | null): string {
interface IProps extends IBodyProps {
/* whether or not to show the default placeholder for the file. Defaults to true. */
showGenericPlaceholder: boolean;
showGenericPlaceholder?: boolean;
}
interface IState {
@@ -105,11 +105,6 @@ export default class MFileBody extends React.Component<IProps, IState> {
declare public context: React.ContextType<typeof RoomContext>;
public state: IState = {};
public static defaultProps = {
showGenericPlaceholder: true,
};
private iframe: React.RefObject<HTMLIFrameElement> = createRef();
private dummyLink: React.RefObject<HTMLAnchorElement> = createRef();
private userDidClick = false;
@@ -191,15 +186,17 @@ export default class MFileBody extends React.Component<IProps, IState> {
const contentUrl = this.getContentUrl();
const contentFileSize = this.content.info ? this.content.info.size : null;
const fileType = this.content.info?.mimetype ?? "application/octet-stream";
// defaultProps breaks types on IBodyProps, so instead define the default here.
const showGenericPlaceholder = this.props.showGenericPlaceholder ?? true;
let showDownloadLink =
!this.props.showGenericPlaceholder ||
!showGenericPlaceholder ||
(this.context.timelineRenderingType !== TimelineRenderingType.Room &&
this.context.timelineRenderingType !== TimelineRenderingType.Search &&
this.context.timelineRenderingType !== TimelineRenderingType.Pinned);
let placeholder: React.ReactNode = null;
if (this.props.showGenericPlaceholder) {
if (showGenericPlaceholder) {
placeholder = (
<AccessibleButton className="mx_MediaBody mx_MFileBody_info" onClick={this.onPlaceholderClick}>
<span className="mx_MFileBody_info_icon" />

View File

@@ -33,6 +33,7 @@ import { presentableTextForFile } from "../../../utils/FileUtils";
import { createReconnectedListener } from "../../../utils/connection";
import MediaProcessingError from "./shared/MediaProcessingError";
import { DecryptError, DownloadError } from "../../../utils/DecryptFile";
import { useMediaVisible } from "../../../hooks/useMediaVisible";
enum Placeholder {
NoImage,
@@ -52,11 +53,25 @@ interface IState {
};
hover: boolean;
focus: boolean;
showImage: boolean;
placeholder: Placeholder;
}
export default class MImageBody extends React.Component<IBodyProps, IState> {
interface IProps extends IBodyProps {
/**
* Should the media be behind a preview.
*/
mediaVisible: boolean;
/**
* Set the visibility of the media event.
* @param visible Should the event be visible.
*/
setMediaVisible: (visible: boolean) => void;
}
/**
* @private Only use for inheritance. Use the default export for presentation.
*/
export class MImageBodyInner extends React.Component<IProps, IState> {
public static contextType = RoomContext;
declare public context: React.ContextType<typeof RoomContext>;
@@ -73,21 +88,14 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
imgLoaded: false,
hover: false,
focus: false,
showImage: SettingsStore.getValue("showImages"),
placeholder: Placeholder.NoImage,
};
protected showImage(): void {
localStorage.setItem("mx_ShowImage_" + this.props.mxEvent.getId(), "true");
this.setState({ showImage: true });
this.downloadImage();
}
protected onClick = (ev: React.MouseEvent): void => {
if (ev.button === 0 && !ev.metaKey) {
ev.preventDefault();
if (!this.state.showImage) {
this.showImage();
if (!this.props.mediaVisible) {
this.props.setMediaVisible?.(true);
return;
}
@@ -125,7 +133,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
private get shouldAutoplay(): boolean {
return !(
!this.state.contentUrl ||
!this.state.showImage ||
!this.props.mediaVisible ||
!this.state.isAnimated ||
SettingsStore.getValue("autoplayGifs")
);
@@ -346,14 +354,10 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
public componentDidMount(): void {
this.unmounted = false;
const showImage =
this.state.showImage || localStorage.getItem("mx_ShowImage_" + this.props.mxEvent.getId()) === "true";
if (showImage) {
if (this.props.mediaVisible) {
// noinspection JSIgnoredPromiseFromCall
this.downloadImage();
this.setState({ showImage: true });
} // else don't download anything because we don't want to display anything.
}
// Add a 150ms timer for blurhash to first appear.
if (this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]) {
@@ -372,6 +376,13 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
});
}
public componentDidUpdate(prevProps: Readonly<IProps>): void {
if (!prevProps.mediaVisible && this.props.mediaVisible) {
// noinspection JSIgnoredPromiseFromCall
this.downloadImage();
}
}
public componentWillUnmount(): void {
this.unmounted = true;
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener);
@@ -425,7 +436,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
// by the same width and height logic below.
if (!this.state.loadedImageDimensions) {
let imageElement: JSX.Element;
if (!this.state.showImage) {
if (!this.props.mediaVisible) {
imageElement = <HiddenImagePlaceholder />;
} else {
imageElement = (
@@ -495,7 +506,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
);
}
if (!this.state.showImage) {
if (!this.props.mediaVisible) {
img = <HiddenImagePlaceholder maxWidth={maxWidth} />;
showPlaceholder = false; // because we're hiding the image, so don't show the placeholder.
}
@@ -506,7 +517,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
}
let banner: ReactNode | undefined;
if (this.state.showImage && hoverOrFocus) {
if (this.props.mediaVisible && hoverOrFocus) {
banner = this.getBanner(content);
}
@@ -585,7 +596,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
{children}
</a>
);
} else if (!this.state.showImage) {
} else if (!this.props.mediaVisible) {
return (
<div role="button" onClick={this.onClick}>
{children}
@@ -686,3 +697,10 @@ export class HiddenImagePlaceholder extends React.PureComponent<PlaceholderIProp
);
}
}
const MImageBody: React.FC<IBodyProps> = (props) => {
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!);
return <MImageBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
};
export default MImageBody;

View File

@@ -9,11 +9,13 @@ Please see LICENSE files in the repository root for full details.
import React from "react";
import { type ImageContent } from "matrix-js-sdk/src/types";
import MImageBody from "./MImageBody";
import { MImageBodyInner } from "./MImageBody";
import { type IBodyProps } from "./IBodyProps";
import { useMediaVisible } from "../../../hooks/useMediaVisible";
const FORCED_IMAGE_HEIGHT = 44;
export default class MImageReplyBody extends MImageBody {
class MImageReplyBodyInner extends MImageBodyInner {
public onClick = (ev: React.MouseEvent): void => {
ev.preventDefault();
};
@@ -35,3 +37,9 @@ export default class MImageReplyBody extends MImageBody {
return <div className="mx_MImageReplyBody">{thumbnail}</div>;
}
}
const MImageReplyBody: React.FC<IBodyProps> = (props) => {
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!);
return <MImageReplyBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
};
export default MImageReplyBody;

View File

@@ -9,16 +9,18 @@ import React, { type ComponentProps, type ReactNode } from "react";
import { type Tooltip } from "@vector-im/compound-web";
import { type MediaEventContent } from "matrix-js-sdk/src/types";
import MImageBody from "./MImageBody";
import { MImageBodyInner } from "./MImageBody";
import { BLURHASH_FIELD } from "../../../utils/image-media";
import IconsShowStickersSvg from "../../../../res/img/icons-show-stickers.svg";
import { type IBodyProps } from "./IBodyProps";
import { useMediaVisible } from "../../../hooks/useMediaVisible";
export default class MStickerBody extends MImageBody {
class MStickerBodyInner extends MImageBodyInner {
// Mostly empty to prevent default behaviour of MImageBody
protected onClick = (ev: React.MouseEvent): void => {
ev.preventDefault();
if (!this.state.showImage) {
this.showImage();
if (!this.props.mediaVisible) {
this.props.setMediaVisible?.(true);
}
};
@@ -26,7 +28,7 @@ export default class MStickerBody extends MImageBody {
// which is added by mx_MStickerBody_wrapper
protected wrapImage(contentUrl: string, children: React.ReactNode): JSX.Element {
let onClick: React.MouseEventHandler | undefined;
if (!this.state.showImage) {
if (!this.props.mediaVisible) {
onClick = this.onClick;
}
return (
@@ -75,3 +77,10 @@ export default class MStickerBody extends MImageBody {
return null; // we don't need a banner, we have a tooltip
}
}
const MStickerBody: React.FC<IBodyProps> = (props) => {
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!);
return <MStickerBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
};
export default MStickerBody;

View File

@@ -61,6 +61,7 @@ import { type GetRelationsForEvent, type IEventTileType } from "../rooms/EventTi
import { type ButtonEvent } from "../elements/AccessibleButton";
import PinningUtils from "../../../utils/PinningUtils";
import PosthogTrackers from "../../../PosthogTrackers.ts";
import { HideActionButton } from "./HideActionButton.tsx";
interface IOptionsButtonProps {
mxEvent: MatrixEvent;
@@ -535,6 +536,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
mediaEventHelperGet={() => this.props.getTile()?.getMediaHelper?.()}
key="download"
/>,
<HideActionButton mxEvent={this.props.mxEvent} key="hide" />,
);
}
} else if (

View File

@@ -45,8 +45,8 @@ import { type GetRelationsForEvent, type IEventTileOps } from "../rooms/EventTil
// onMessageAllowed is handled internally
interface IProps extends Omit<IBodyProps, "onMessageAllowed" | "mediaEventHelper"> {
/* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */
overrideBodyTypes?: Record<string, typeof React.Component>;
overrideEventTypes?: Record<string, typeof React.Component>;
overrideBodyTypes?: Record<string, React.ComponentType<IBodyProps>>;
overrideEventTypes?: Record<string, React.ComponentType<IBodyProps>>;
// helper function to access relations for this event
getRelationsForEvent?: GetRelationsForEvent;
@@ -58,7 +58,7 @@ export interface IOperableEventTile {
getEventTileOps(): IEventTileOps | null;
}
const baseBodyTypes = new Map<string, typeof React.Component>([
const baseBodyTypes = new Map<string, React.ComponentType<IBodyProps>>([
[MsgType.Text, TextualBody],
[MsgType.Notice, TextualBody],
[MsgType.Emote, TextualBody],
@@ -80,7 +80,7 @@ const baseEvTypes = new Map<string, React.ComponentType<IBodyProps>>([
export default class MessageEvent extends React.Component<IProps> implements IMediaBody, IOperableEventTile {
private body: React.RefObject<React.Component | IOperableEventTile> = createRef();
private mediaHelper?: MediaEventHelper;
private bodyTypes = new Map<string, typeof React.Component>(baseBodyTypes.entries());
private bodyTypes = new Map<string, React.ComponentType<IBodyProps>>(baseBodyTypes.entries());
private evTypes = new Map<string, React.ComponentType<IBodyProps>>(baseEvTypes.entries());
public static contextType = MatrixClientContext;
@@ -115,7 +115,7 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
}
private updateComponentMaps(): void {
this.bodyTypes = new Map<string, typeof React.Component>(baseBodyTypes.entries());
this.bodyTypes = new Map<string, React.ComponentType<IBodyProps>>(baseBodyTypes.entries());
for (const [bodyType, bodyComponent] of Object.entries(this.props.overrideBodyTypes ?? {})) {
this.bodyTypes.set(bodyType, bodyComponent);
}

View File

@@ -26,6 +26,7 @@ import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPaylo
import { renderReplyTile } from "../../../events/EventTileFactory";
import { type GetRelationsForEvent } from "../rooms/EventTile";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { type IBodyProps } from "../messages/IBodyProps";
interface IProps {
mxEvent: MatrixEvent;
@@ -139,13 +140,13 @@ export default class ReplyTile extends React.PureComponent<IProps> {
);
}
const msgtypeOverrides: Record<string, typeof React.Component> = {
const msgtypeOverrides: Record<string, React.ComponentType<IBodyProps>> = {
[MsgType.Image]: MImageReplyBody,
// Override audio and video body with file body. We also hide the download/decrypt button using CSS
[MsgType.Audio]: isVoiceMessage(mxEvent) ? MVoiceMessageBody : MFileBody,
[MsgType.Video]: MFileBody,
};
const evOverrides: Record<string, typeof React.Component> = {
const evOverrides: Record<string, React.ComponentType<IBodyProps>> = {
// Use MImageReplyBody so that the sticker isn't taking up a lot of space
[EventType.Sticker]: MImageReplyBody,
};

View File

@@ -10,7 +10,7 @@ import { AutoSizer, List, type ListRowProps } from "react-virtualized";
import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { _t } from "../../../../languageHandler";
import { RoomListCell } from "./RoomListCell";
import { RoomListItemView } from "./RoomListItemView";
interface RoomListProps {
/**
@@ -22,12 +22,10 @@ interface RoomListProps {
/**
* A virtualized list of rooms.
*/
export function RoomList({ vm: { rooms, openRoom } }: RoomListProps): JSX.Element {
export function RoomList({ vm: { rooms } }: RoomListProps): JSX.Element {
const roomRendererMemoized = useCallback(
({ key, index, style }: ListRowProps) => (
<RoomListCell room={rooms[index]} key={key} style={style} onClick={() => openRoom(rooms[index].roomId)} />
),
[rooms, openRoom],
({ key, index, style }: ListRowProps) => <RoomListItemView room={rooms[index]} key={key} style={style} />,
[rooms],
);
// The first div is needed to make the virtualized list take all the remaining space and scroll correctly

View File

@@ -1,44 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../../languageHandler";
import { Flex } from "../../../utils/Flex";
import DecoratedRoomAvatar from "../../avatars/DecoratedRoomAvatar";
interface RoomListCellProps extends React.HTMLAttributes<HTMLButtonElement> {
/**
* The room to display
*/
room: Room;
}
/**
* A cell in the room list
*/
export function RoomListCell({ room, ...props }: RoomListCellProps): JSX.Element {
return (
<button
className="mx_RoomListCell"
type="button"
aria-label={_t("room_list|room|open_room", { roomName: room.name })}
{...props}
>
{/* We need this extra div between the button and the content in order to add a padding which is not messing with the virtualized list */}
<Flex className="mx_RoomListCell_container" gap="var(--cpd-space-3x)" align="center">
<DecoratedRoomAvatar room={room} size="32px" />
<Flex className="mx_RoomListCell_content" align="center">
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
<span title={room.name}>{room.name}</span>
{/* Future hover menu et notification badges */}
</Flex>
</Flex>
</button>
);
}

View File

@@ -42,7 +42,14 @@ export function RoomListHeaderView(): JSX.Element {
<h1 title={vm.title}>{vm.title}</h1>
{vm.displaySpaceMenu && <SpaceMenu vm={vm} />}
</Flex>
{vm.displayComposeMenu && <ComposeMenu vm={vm} />}
{/* If we don't display the compose menu, it means that the user can only send DM */}
{vm.displayComposeMenu ? (
<ComposeMenu vm={vm} />
) : (
<IconButton aria-label={_t("action|new_message")} onClick={(e) => vm.createChatRoom(e.nativeEvent)}>
<ComposeIcon />
</IconButton>
)}
</Flex>
);
}

View File

@@ -0,0 +1,154 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type ComponentProps, forwardRef, type JSX, useState } from "react";
import { IconButton, Menu, MenuItem, Separator, ToggleMenuItem, Tooltip } from "@vector-im/compound-web";
import MarkAsReadIcon from "@vector-im/compound-design-tokens/assets/web/icons/mark-as-read";
import MarkAsUnreadIcon from "@vector-im/compound-design-tokens/assets/web/icons/mark-as-unread";
import FavouriteIcon from "@vector-im/compound-design-tokens/assets/web/icons/favourite";
import ArrowDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/arrow-down";
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add";
import LinkIcon from "@vector-im/compound-design-tokens/assets/web/icons/link";
import LeaveIcon from "@vector-im/compound-design-tokens/assets/web/icons/leave";
import OverflowIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
import { type Room } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../../languageHandler";
import { Flex } from "../../../utils/Flex";
import {
type RoomListItemMenuViewState,
useRoomListItemMenuViewModel,
} from "../../../viewmodels/roomlist/RoomListItemMenuViewModel";
interface RoomListItemMenuViewProps {
/**
* The room to display the menu for.
*/
room: Room;
/**
* Set the menu open state.
* @param isOpen
*/
setMenuOpen: (isOpen: boolean) => void;
}
/**
* A view for the room list item menu.
*/
export function RoomListItemMenuView({ room, setMenuOpen }: RoomListItemMenuViewProps): JSX.Element {
const vm = useRoomListItemMenuViewModel(room);
return (
<Flex className="mx_RoomListItemMenuView" align="center" gap="var(--cpd-space-1x)">
{vm.showMoreOptionsMenu && <MoreOptionsMenu setMenuOpen={setMenuOpen} vm={vm} />}
</Flex>
);
}
interface MoreOptionsMenuProps {
/**
* The view model state for the menu.
*/
vm: RoomListItemMenuViewState;
/**
* Set the menu open state.
* @param isOpen
*/
setMenuOpen: (isOpen: boolean) => void;
}
/**
* The more options menu for the room list item.
*/
function MoreOptionsMenu({ vm, setMenuOpen }: MoreOptionsMenuProps): JSX.Element {
const [open, setOpen] = useState(false);
return (
<Menu
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
setMenuOpen(isOpen);
}}
title={_t("room_list|room|more_options")}
showTitle={false}
align="start"
trigger={<MoreOptionsButton />}
>
{vm.canMarkAsRead && (
<MenuItem
Icon={MarkAsReadIcon}
label={_t("room_list|more_options|mark_read")}
onSelect={vm.markAsRead}
onClick={(evt) => evt.stopPropagation()}
/>
)}
{vm.canMarkAsUnread && (
<MenuItem
Icon={MarkAsUnreadIcon}
label={_t("room_list|more_options|mark_unread")}
onSelect={vm.markAsUnread}
onClick={(evt) => evt.stopPropagation()}
/>
)}
<ToggleMenuItem
checked={vm.isFavourite}
Icon={FavouriteIcon}
label={_t("room_list|more_options|favourited")}
onSelect={vm.toggleFavorite}
onClick={(evt) => evt.stopPropagation()}
/>
<MenuItem
Icon={ArrowDownIcon}
label={_t("room_list|more_options|low_priority")}
onSelect={vm.toggleLowPriority}
onClick={(evt) => evt.stopPropagation()}
/>
{vm.canInvite && (
<MenuItem
Icon={UserAddIcon}
label={_t("action|invite")}
onSelect={vm.invite}
onClick={(evt) => evt.stopPropagation()}
/>
)}
{vm.canCopyRoomLink && (
<MenuItem
Icon={LinkIcon}
label={_t("room_list|more_options|copy_link")}
onSelect={vm.copyRoomLink}
onClick={(evt) => evt.stopPropagation()}
/>
)}
<Separator />
<MenuItem
kind="critical"
Icon={LeaveIcon}
label={_t("room_list|more_options|leave_room")}
onSelect={vm.leaveRoom}
onClick={(evt) => evt.stopPropagation()}
/>
</Menu>
);
}
interface MoreOptionsButtonProps extends ComponentProps<typeof IconButton> {}
/**
* A button to trigger the more options menu.
*/
export const MoreOptionsButton = forwardRef<HTMLButtonElement, MoreOptionsButtonProps>(
function MoreOptionsButton(props, ref) {
return (
<Tooltip label={_t("room_list|room|more_options")}>
<IconButton aria-label={_t("room_list|room|more_options")} {...props} ref={ref}>
<OverflowIcon />
</IconButton>
</Tooltip>
);
},
);

View File

@@ -0,0 +1,76 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, useState } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { useRoomListItemViewModel } from "../../../viewmodels/roomlist/RoomListItemViewModel";
import DecoratedRoomAvatar from "../../avatars/DecoratedRoomAvatar";
import { Flex } from "../../../utils/Flex";
import { _t } from "../../../../languageHandler";
import { RoomListItemMenuView } from "./RoomListItemMenuView";
interface RoomListItemViewPropsProps extends React.HTMLAttributes<HTMLButtonElement> {
/**
* The room to display
*/
room: Room;
}
/**
* An item in the room list
*/
export function RoomListItemView({ room, ...props }: RoomListItemViewPropsProps): JSX.Element {
const vm = useRoomListItemViewModel(room);
const [isHover, setIsHover] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
// The compound menu in RoomListItemMenuView needs to be rendered when the hover menu is shown
// Using display: none; and then display:flex when hovered in CSS causes the menu to be misaligned
const showHoverDecoration = (isMenuOpen || isHover) && vm.showHoverMenu;
return (
<button
className={classNames("mx_RoomListItemView", {
mx_RoomListItemView_menu_open: showHoverDecoration,
})}
type="button"
aria-label={_t("room_list|room|open_room", { roomName: room.name })}
onClick={() => vm.openRoom()}
onMouseOver={() => setIsHover(true)}
onMouseOut={() => setIsHover(false)}
onFocus={() => setIsHover(true)}
onBlur={() => setIsHover(false)}
{...props}
>
{/* We need this extra div between the button and the content in order to add a padding which is not messing with the virtualized list */}
<Flex className="mx_RoomListItemView_container" gap="var(--cpd-space-3x)" align="center">
<DecoratedRoomAvatar room={room} size="32px" />
<Flex
className="mx_RoomListItemView_content"
gap="var(--cpd-space-3x)"
align="center"
justify="space-between"
>
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
<span title={room.name}>{room.name}</span>
{showHoverDecoration && (
<RoomListItemMenuView
room={room}
setMenuOpen={(isOpen) => {
if (isOpen) setIsMenuOpen(isOpen);
// To avoid icon blinking when closing the menu, we delay the state update
else setTimeout(() => setIsMenuOpen(isOpen), 0);
}}
/>
)}
</Flex>
</Flex>
</button>
);
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { ChatFilter } from "@vector-im/compound-web";
import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { Flex } from "../../../utils/Flex";
import { _t } from "../../../../languageHandler";
interface RoomListPrimaryFiltersProps {
/**
* The view model for the room list
*/
vm: RoomListViewState;
}
/**
* The primary filters for the room list
*/
export function RoomListPrimaryFilters({ vm }: RoomListPrimaryFiltersProps): JSX.Element {
return (
<Flex
as="ul"
role="listbox"
aria-label={_t("room_list|primary_filters")}
className="mx_RoomListPrimaryFilters"
align="center"
gap="var(--cpd-space-2x)"
wrap="wrap"
>
{vm.primaryFilters.map((filter) => (
<li role="option" aria-selected={filter.active} key={filter.name}>
<ChatFilter selected={filter.active} onClick={filter.toggle}>
{filter.name}
</ChatFilter>
</li>
))}
</Flex>
);
}

View File

@@ -9,12 +9,17 @@ import React, { type JSX } from "react";
import { useRoomListViewModel } from "../../../viewmodels/roomlist/RoomListViewModel";
import { RoomList } from "./RoomList";
import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
/**
* Host the room list and the (future) room filters
*/
export function RoomListView(): JSX.Element {
const vm = useRoomListViewModel();
// Room filters will be added soon
return <RoomList vm={vm} />;
return (
<>
<RoomListPrimaryFilters vm={vm} />
<RoomList vm={vm} />
</>
);
}

View File

@@ -95,16 +95,9 @@ export function attachMentions(
const userMentions = new Set<string>();
let roomMention = false;
// If there's a reply, initialize the mentioned users as the sender of that
// event + any mentioned users in that event.
// If there's a reply, initialize the mentioned users as the sender of that event.
if (replyToEvent) {
userMentions.add(replyToEvent.sender!.userId);
// TODO What do we do if the reply event *doeesn't* have this property?
// Try to fish out replies from the contents?
const userIds = replyToEvent.getContent()["m.mentions"]?.user_ids;
if (Array.isArray(userIds)) {
userIds.forEach((userId) => userMentions.add(userId));
}
}
// If user provided content is available, check to see if any users are mentioned.

View File

@@ -0,0 +1,79 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { Breadcrumb, Button, VisualList, VisualListItem } from "@vector-im/compound-web";
import CrossIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
import React, { useCallback, useState } from "react";
import { _t } from "../../../../languageHandler";
import { EncryptionCard } from "./EncryptionCard";
import { useKeyStoragePanelViewModel } from "../../../viewmodels/settings/encryption/KeyStoragePanelViewModel";
import SdkConfig from "../../../../SdkConfig";
import { EncryptionCardButtons } from "./EncryptionCardButtons";
import { EncryptionCardEmphasisedContent } from "./EncryptionCardEmphasisedContent";
interface Props {
/**
* Called when the user either cancels the operation or key storage has been disabled
*/
onFinish: () => void;
}
/**
* Confirms that the user really wants to turn off and delete their key storage. Part of the "Encryption" settings tab.
*/
export function DeleteKeyStoragePanel({ onFinish }: Props): JSX.Element {
const { setEnabled } = useKeyStoragePanelViewModel();
const [busy, setBusy] = useState(false);
const onDeleteClick = useCallback(async () => {
setBusy(true);
try {
await setEnabled(false);
} finally {
setBusy(false);
}
onFinish();
}, [setEnabled, onFinish]);
return (
<>
<Breadcrumb
backLabel={_t("action|back")}
onBackClick={onFinish}
pages={[_t("settings|encryption|title"), _t("settings|encryption|delete_key_storage|breadcrumb_page")]}
onPageClick={onFinish}
/>
<EncryptionCard
Icon={ErrorIcon}
destructive={true}
title={_t("settings|encryption|delete_key_storage|title")}
>
<EncryptionCardEmphasisedContent>
{_t("settings|encryption|delete_key_storage|description")}
<VisualList>
<VisualListItem Icon={CrossIcon} destructive={true}>
{_t("settings|encryption|delete_key_storage|list_first")}
</VisualListItem>
<VisualListItem Icon={CrossIcon} destructive={true}>
{_t("settings|encryption|delete_key_storage|list_second", { brand: SdkConfig.get().brand })}
</VisualListItem>
</VisualList>
</EncryptionCardEmphasisedContent>
<EncryptionCardButtons>
<Button destructive={true} onClick={onDeleteClick} disabled={busy}>
{_t("settings|encryption|delete_key_storage|confirm")}
</Button>
<Button kind="tertiary" onClick={onFinish}>
{_t("action|cancel")}
</Button>
</EncryptionCardButtons>
</EncryptionCard>
</>
);
}

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