Compare commits
45 Commits
v1.11.95
...
dbkr/key_s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0162c82673 | ||
|
|
a5cec9e59e | ||
|
|
98edffac98 | ||
|
|
96a70f24a6 | ||
|
|
d1aef9f09a | ||
|
|
c4525b97b6 | ||
|
|
de183113e2 | ||
|
|
f0d9e05f85 | ||
|
|
9c4625d6a1 | ||
|
|
0c5f2b07b4 | ||
|
|
64f84cb4f8 | ||
|
|
7149b3d019 | ||
|
|
2ef05c5cb9 | ||
|
|
8ca4a8b6ec | ||
|
|
e8483e0186 | ||
|
|
f586c43a26 | ||
|
|
cfd55a6887 | ||
|
|
7c2d9f4954 | ||
|
|
1178d77fb8 | ||
|
|
25f8fe2009 | ||
|
|
fc9bc0903c | ||
|
|
f818d6e600 | ||
|
|
16c76cb20d | ||
|
|
40f9bd9480 | ||
|
|
e70afdb04f | ||
|
|
4a3a37323e | ||
|
|
df4c23bec7 | ||
|
|
4ea6a33497 | ||
|
|
87d44a7792 | ||
|
|
6b238d1fdc | ||
|
|
1b99071dfc | ||
|
|
a26efc58f1 | ||
|
|
130458783f | ||
|
|
5ac200492c | ||
|
|
aa6de76a8b | ||
|
|
e408715335 | ||
|
|
b7b2ea3448 | ||
|
|
6a20703ebc | ||
|
|
98114c8060 | ||
|
|
4db196e6bd | ||
|
|
4ec09f0063 | ||
|
|
97057de900 | ||
|
|
4eb07d4dda | ||
|
|
98950ded60 | ||
|
|
5a74298d66 |
5
.github/CODEOWNERS
vendored
@@ -11,11 +11,10 @@
|
||||
/src/stores/SetupEncryptionStore.ts @element-hq/element-crypto-web-reviewers
|
||||
/test/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers
|
||||
/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @element-hq/element-crypto-web-reviewers
|
||||
/src/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
|
||||
/src/src/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
|
||||
/test/unit-tests/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
|
||||
/src/components/views/dialogs/devtools/Crypto.tsx @element-hq/element-crypto-web-reviewers
|
||||
/playwright/e2e/crypto/ @element-hq/element-crypto-web-reviewers
|
||||
/playwright/e2e/settings/encryption-user-tab/ @element-hq/element-crypto-web-reviewers
|
||||
/src/components/views/dialogs/devtools/Crypto.tsx @element-hq/element-crypto-web-reviewers
|
||||
|
||||
# Ignore translations as those will be updated by GHA for Localazy download
|
||||
/src/i18n/strings
|
||||
|
||||
138
.github/workflows/docker.yaml
vendored
@@ -1,138 +0,0 @@
|
||||
name: Docker
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
push:
|
||||
tags: [v*]
|
||||
pull_request: {}
|
||||
schedule:
|
||||
# This job can take a while, and we have usage limits, so just publish develop only twice a day
|
||||
- cron: "0 7/12 * * *"
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref_name }}
|
||||
permissions: {}
|
||||
jobs:
|
||||
buildx:
|
||||
name: Docker Buildx
|
||||
runs-on: ubuntu-24.04
|
||||
environment: ${{ github.event_name != 'pull_request' && 'dockerhub' || '' }}
|
||||
permissions:
|
||||
id-token: write # needed for signing the images with GitHub OIDC Token
|
||||
packages: write # needed for publishing packages to GHCR
|
||||
env:
|
||||
TEST_TAG: vectorim/element-web:test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # needed for docker-package to be able to calculate the version
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@c56c2d3e59e4281cc41dea2217323ba5694b171e # v3
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@4574d27a4764455b42196d70a065bc6853246a25 # v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@f7ce87c1d6bead3e36075b2ce75da1f6cc28aaca # v3
|
||||
with:
|
||||
install: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and load
|
||||
id: test-build
|
||||
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6
|
||||
with:
|
||||
context: .
|
||||
load: true
|
||||
|
||||
- name: Test the image
|
||||
env:
|
||||
IMAGEID: ${{ steps.test-build.outputs.imageid }}
|
||||
run: |
|
||||
set -x
|
||||
|
||||
# Make a fake module to test the image
|
||||
MODULE_PATH="modules/module_name/index.js"
|
||||
mkdir -p $(dirname $MODULE_PATH)
|
||||
echo 'alert("Testing");' > $MODULE_PATH
|
||||
|
||||
# Spin up a container of the image
|
||||
ELEMENT_WEB_PORT=8181
|
||||
CONTAINER_ID=$(
|
||||
docker run \
|
||||
--rm \
|
||||
-e "ELEMENT_WEB_PORT=$ELEMENT_WEB_PORT" \
|
||||
-dp "$ELEMENT_WEB_PORT:$ELEMENT_WEB_PORT" \
|
||||
-v $(pwd)/modules:/tmp/element-web-modules \
|
||||
"$IMAGEID" \
|
||||
)
|
||||
|
||||
# Run some smoke tests
|
||||
wget --retry-connrefused --tries=5 -q --wait=3 --spider "http://localhost:$ELEMENT_WEB_PORT/modules/module_name/index.js"
|
||||
MODULE_0=$(curl "http://localhost:$ELEMENT_WEB_PORT/config.json" | jq -r .modules[0])
|
||||
test "$MODULE_0" = "/${MODULE_PATH}"
|
||||
|
||||
# Check healthcheck
|
||||
test "$(docker inspect -f {{.State.Running}} $CONTAINER_ID)" == "true"
|
||||
|
||||
# Clean up
|
||||
docker stop "$CONTAINER_ID"
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
images: |
|
||||
vectorim/element-web
|
||||
ghcr.io/element-hq/element-web
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
flavor: |
|
||||
latest=${{ contains(github.ref_name, '-rc.') && 'false' || 'auto' }}
|
||||
|
||||
- name: Build and push
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Sign the images with GitHub OIDC Token
|
||||
env:
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
images=""
|
||||
for tag in ${TAGS}; do
|
||||
images+="${tag}@${DIGEST} "
|
||||
done
|
||||
cosign sign --yes ${images}
|
||||
|
||||
- name: Update repo description
|
||||
uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4
|
||||
if: github.event_name != 'pull_request'
|
||||
continue-on-error: true
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
repository: vectorim/element-web
|
||||
79
.github/workflows/dockerhub.yaml
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
name: Dockerhub
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
push:
|
||||
tags: [v*]
|
||||
schedule:
|
||||
# This job can take a while, and we have usage limits, so just publish develop only twice a day
|
||||
- cron: "0 7/12 * * *"
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref_name }}
|
||||
permissions: {}
|
||||
jobs:
|
||||
buildx:
|
||||
name: Docker Buildx
|
||||
runs-on: ubuntu-24.04
|
||||
environment: dockerhub
|
||||
permissions:
|
||||
id-token: write # needed for signing the images with GitHub OIDC Token
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # needed for docker-package to be able to calculate the version
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@53851d14592bedcffcf25ea515637cff71ef929a # v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 # v3
|
||||
with:
|
||||
install: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5
|
||||
with:
|
||||
images: |
|
||||
vectorim/element-web
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
flavor: |
|
||||
latest=${{ contains(github.ref_name, '-rc.') && 'false' || 'auto' }}
|
||||
|
||||
- name: Build and push
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Sign the images with GitHub OIDC Token
|
||||
env:
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
run: |
|
||||
images=""
|
||||
for tag in ${TAGS}; do
|
||||
images+="${tag}@${DIGEST} "
|
||||
done
|
||||
cosign sign --yes ${images}
|
||||
|
||||
- name: Update repo description
|
||||
uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
repository: vectorim/element-web
|
||||
3
.github/workflows/release.yml
vendored
@@ -19,7 +19,6 @@ jobs:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: read
|
||||
id-token: write
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
@@ -51,7 +50,7 @@ jobs:
|
||||
permissions:
|
||||
checks: read
|
||||
steps:
|
||||
- name: Wait for docker build
|
||||
- name: Wait for dockerhub
|
||||
uses: t3chguy/wait-on-check-action@18541021811b56544d90e0f073401c2b99e249d6 # fork
|
||||
with:
|
||||
ref: master
|
||||
|
||||
2
.github/workflows/tests.yml
vendored
@@ -104,7 +104,7 @@ jobs:
|
||||
|
||||
- name: Skip SonarCloud in merge queue
|
||||
if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true'
|
||||
uses: guibranco/github-status-action-v2@7ca807c2ba3401be532d29a876b93262108099fb
|
||||
uses: guibranco/github-status-action-v2@119b3320db3f04d89e91df840844b92d57ce3468
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: success
|
||||
|
||||
95
CHANGELOG.md
@@ -1,98 +1,3 @@
|
||||
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
|
||||
|
||||
* [Backport staging] fix: /tmp/element-web-config may already exist preventing the container from booting up ([#29377](https://github.com/element-hq/element-web/pull/29377)). Contributed by @RiotRobot.
|
||||
|
||||
|
||||
Changes in [1.11.93](https://github.com/element-hq/element-web/releases/tag/v1.11.93) (2025-02-25)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* [backport] Dynamically load Element Web modules in Docker entrypoint ([#29358](https://github.com/element-hq/element-web/pull/29358)). Contributed by @t3chguy.
|
||||
* ChangeRecoveryKey: error handling ([#29262](https://github.com/element-hq/element-web/pull/29262)). Contributed by @richvdh.
|
||||
* Dehydration: enable dehydrated device on "Set up recovery" ([#29265](https://github.com/element-hq/element-web/pull/29265)). Contributed by @richvdh.
|
||||
* Render reason for invite rejection. ([#29257](https://github.com/element-hq/element-web/pull/29257)). Contributed by @Half-Shot.
|
||||
* New room list: add search section ([#29251](https://github.com/element-hq/element-web/pull/29251)). Contributed by @florianduros.
|
||||
* New room list: hide favourites and people meta spaces ([#29241](https://github.com/element-hq/element-web/pull/29241)). Contributed by @florianduros.
|
||||
* New Room List: Create new labs flag ([#29239](https://github.com/element-hq/element-web/pull/29239)). Contributed by @MidhunSureshR.
|
||||
* Stop URl preview from covering message box ([#29215](https://github.com/element-hq/element-web/pull/29215)). Contributed by @edent.
|
||||
* Rename "security key" into "recovery key" ([#29217](https://github.com/element-hq/element-web/pull/29217)). Contributed by @florianduros.
|
||||
* Add new verification section to user profile ([#29200](https://github.com/element-hq/element-web/pull/29200)). Contributed by @MidhunSureshR.
|
||||
* Initial support for runtime modules ([#29104](https://github.com/element-hq/element-web/pull/29104)). Contributed by @t3chguy.
|
||||
* Add `Forgot recovery key?` button to encryption tab ([#29202](https://github.com/element-hq/element-web/pull/29202)). Contributed by @florianduros.
|
||||
* Add KeyIcon to key storage out of sync toast ([#29201](https://github.com/element-hq/element-web/pull/29201)). Contributed by @florianduros.
|
||||
* Improve rendering of empty topics in the timeline ([#29152](https://github.com/element-hq/element-web/pull/29152)). Contributed by @Half-Shot.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix font scaling in member list ([#29285](https://github.com/element-hq/element-web/pull/29285)). Contributed by @florianduros.
|
||||
* Grow member list search field when resizing the right panel ([#29267](https://github.com/element-hq/element-web/pull/29267)). Contributed by @langleyd.
|
||||
* Don't reload roomview on offline connectivity check ([#29243](https://github.com/element-hq/element-web/pull/29243)). Contributed by @dbkr.
|
||||
* Respect user's 12/24 hour preference consistently ([#29237](https://github.com/element-hq/element-web/pull/29237)). Contributed by @t3chguy.
|
||||
* Restore the accessibility role on call views ([#29225](https://github.com/element-hq/element-web/pull/29225)). Contributed by @robintown.
|
||||
* Revert `GoToHome` keyboard shortcut to `Ctrl`–`Shift`–`H` on macOS ([#28577](https://github.com/element-hq/element-web/pull/28577)). Contributed by @gy-mate.
|
||||
* Encryption tab: display correct encryption panel when user cancels the reset identity flow ([#29216](https://github.com/element-hq/element-web/pull/29216)). Contributed by @florianduros.
|
||||
|
||||
|
||||
Changes in [1.11.92](https://github.com/element-hq/element-web/releases/tag/v1.11.92) (2025-02-11)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* [Backport staging] Log when we show, and hide, encryption setup toasts ([#29238](https://github.com/element-hq/element-web/pull/29238)). Contributed by @richvdh.
|
||||
* Make profile header section match the designs ([#29163](https://github.com/element-hq/element-web/pull/29163)). Contributed by @MidhunSureshR.
|
||||
* Always show back button in the right panel ([#29128](https://github.com/element-hq/element-web/pull/29128)). Contributed by @MidhunSureshR.
|
||||
* Schedule dehydration on reload if the dehydration key is already cached locally ([#29021](https://github.com/element-hq/element-web/pull/29021)). Contributed by @uhoreg.
|
||||
* update to twemoji 15.1.0 ([#29115](https://github.com/element-hq/element-web/pull/29115)). Contributed by @ara4n.
|
||||
* Update matrix-widget-api ([#29112](https://github.com/element-hq/element-web/pull/29112)). Contributed by @toger5.
|
||||
* Allow navigating through the memberlist using up/down keys ([#28949](https://github.com/element-hq/element-web/pull/28949)). Contributed by @MidhunSureshR.
|
||||
* Style room header icons and facepile for toggled state ([#28968](https://github.com/element-hq/element-web/pull/28968)). Contributed by @MidhunSureshR.
|
||||
* Move threads header below base card header ([#28969](https://github.com/element-hq/element-web/pull/28969)). Contributed by @MidhunSureshR.
|
||||
* Add `Advanced` section to the user settings encryption tab ([#28804](https://github.com/element-hq/element-web/pull/28804)). Contributed by @florianduros.
|
||||
* Fix outstanding UX issues with replies/mentions/keyword notifs ([#28270](https://github.com/element-hq/element-web/pull/28270)). Contributed by @taffyko.
|
||||
* Distinguish room state and timeline events when dealing with widgets ([#28681](https://github.com/element-hq/element-web/pull/28681)). Contributed by @robintown.
|
||||
* Switch OIDC primarily to new `/auth_metadata` API ([#29019](https://github.com/element-hq/element-web/pull/29019)). Contributed by @t3chguy.
|
||||
* More memberlist changes ([#29069](https://github.com/element-hq/element-web/pull/29069)). Contributed by @MidhunSureshR.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* [Backport staging] Wire up the "Forgot recovery key" button for the "Key storage out of sync" toast ([#29190](https://github.com/element-hq/element-web/pull/29190)). Contributed by @RiotRobot.
|
||||
* Encryption tab: hide `Advanced` section when the key storage is out of sync ([#29129](https://github.com/element-hq/element-web/pull/29129)). Contributed by @florianduros.
|
||||
* Fix share button in discovery settings being disabled incorrectly ([#29151](https://github.com/element-hq/element-web/pull/29151)). Contributed by @t3chguy.
|
||||
* Ensure switching rooms does not wrongly focus timeline search ([#29153](https://github.com/element-hq/element-web/pull/29153)). Contributed by @t3chguy.
|
||||
* Stop showing a dialog prompting the user to enter an old recovery key ([#29143](https://github.com/element-hq/element-web/pull/29143)). Contributed by @richvdh.
|
||||
* Make themed widgets reflect the effective theme ([#28342](https://github.com/element-hq/element-web/pull/28342)). Contributed by @robintown.
|
||||
* support non-VS16 emoji ligatures in TwemojiMozilla ([#29100](https://github.com/element-hq/element-web/pull/29100)). Contributed by @ara4n.
|
||||
* e2e test: Verify session with the encryption tab instead of the security \& privacy tab ([#29090](https://github.com/element-hq/element-web/pull/29090)). Contributed by @florianduros.
|
||||
* Work around cloudflare R2 / aws client incompatability ([#29086](https://github.com/element-hq/element-web/pull/29086)). Contributed by @dbkr.
|
||||
* Fix identity server settings visibility ([#29083](https://github.com/element-hq/element-web/pull/29083)). Contributed by @dbkr.
|
||||
|
||||
|
||||
Changes in [1.11.91](https://github.com/element-hq/element-web/releases/tag/v1.11.91) (2025-01-28)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
10
Dockerfile
@@ -1,5 +1,3 @@
|
||||
# syntax=docker.io/docker/dockerfile:1.7-labs
|
||||
|
||||
# Builder
|
||||
FROM --platform=$BUILDPLATFORM node:22-bullseye AS builder
|
||||
|
||||
@@ -10,7 +8,7 @@ ARG JS_SDK_BRANCH="master"
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY --exclude=docker . /src
|
||||
COPY . /src
|
||||
RUN /src/scripts/docker-link-repos.sh
|
||||
RUN yarn --network-timeout=200000 install
|
||||
RUN /src/scripts/docker-package.sh
|
||||
@@ -21,15 +19,11 @@ RUN cp /src/config.sample.json /src/webapp/config.json
|
||||
# App
|
||||
FROM nginx:alpine-slim
|
||||
|
||||
# Install jq and moreutils for sponge, both used by our entrypoints
|
||||
RUN apk add jq moreutils
|
||||
|
||||
COPY --from=builder /src/webapp /app
|
||||
|
||||
# Override default nginx config. Templates in `/etc/nginx/templates` are passed
|
||||
# through `envsubst` by the nginx docker image entry point.
|
||||
COPY /docker/nginx-templates/* /etc/nginx/templates/
|
||||
COPY /docker/docker-entrypoint.d/* /docker-entrypoint.d/
|
||||
|
||||
# Tell nginx to put its pidfile elsewhere, so it can run as non-root
|
||||
RUN sed -i -e 's,/var/run/nginx.pid,/tmp/nginx.pid,' /etc/nginx/nginx.conf
|
||||
@@ -46,5 +40,3 @@ 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
|
||||
|
||||
@@ -31,7 +31,5 @@ module.exports = {
|
||||
|
||||
"@babel/plugin-syntax-dynamic-import",
|
||||
"@babel/plugin-transform-runtime",
|
||||
["@babel/plugin-proposal-decorators", { version: "2023-11" }], // only needed by the js-sdk
|
||||
"@babel/plugin-transform-class-static-block", // only needed by the js-sdk for decorators
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Loads modules from `/tmp/element-web-modules` into config.json's `modules` field
|
||||
|
||||
set -e
|
||||
|
||||
entrypoint_log() {
|
||||
if [ -z "${NGINX_ENTRYPOINT_QUIET_LOGS:-}" ]; then
|
||||
echo "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Copy these config files as a base
|
||||
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
|
||||
|
||||
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")
|
||||
fi
|
||||
|
||||
entrypoint_log "Loading module $MODULE with entrypoint $ENTRYPOINT"
|
||||
|
||||
# Append the module to the config
|
||||
jq ".modules += [\"/modules/$MODULE/$ENTRYPOINT\"]" /tmp/element-web-config/config.json | sponge /tmp/element-web-config/config.json
|
||||
done
|
||||
fi
|
||||
@@ -18,12 +18,8 @@ server {
|
||||
}
|
||||
# covers config.json and config.hostname.json requests as it is prefix.
|
||||
location /config {
|
||||
root /tmp/element-web-config;
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
location /modules {
|
||||
alias /tmp/element-web-modules;
|
||||
}
|
||||
# redirect server error pages to the static page /50x.html
|
||||
#
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
||||
67
docs/MVVM.md
@@ -1,67 +0,0 @@
|
||||
# MVVM
|
||||
|
||||
General description of the pattern can be found [here](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel). But the gist of it is that you divide your code into three sections:
|
||||
|
||||
1. Model: This is where the business logic and data resides.
|
||||
2. View Model: This code exists to provide the logic necessary for the UI. It directly uses the Model code.
|
||||
3. View: This is the UI code itself and depends on the view model.
|
||||
|
||||
If you do MVVM right, your view should be dumb i.e it gets data from the view model and merely displays it.
|
||||
|
||||
### Practical guidelines for MVVM in element-web
|
||||
|
||||
#### Model
|
||||
|
||||
This is anywhere your data or business logic comes from. If your view model is accessing something simple exposed from `matrix-js-sdk`, then the sdk is your model. If you're using something more high level in element-web to get your data/logic (eg: `MemberListStore`), then that becomes your model.
|
||||
|
||||
#### View Model
|
||||
|
||||
1. View model is always a custom react hook named like `useFooViewModel()`.
|
||||
2. The return type of your view model (known as view state) must be defined as a typescript interface:
|
||||
```ts
|
||||
inteface FooViewState {
|
||||
somethingUseful: string;
|
||||
somethingElse: BarType;
|
||||
update: () => Promise<void>
|
||||
...
|
||||
}
|
||||
```
|
||||
3. Any react state that your UI needs must be in the view model.
|
||||
|
||||
#### View
|
||||
|
||||
1. Views are simple react components (eg: `FooView`).
|
||||
2. Views usually start by calling the view model hook, eg:
|
||||
```tsx
|
||||
const FooView: React.FC<IProps> = (props: IProps) => {
|
||||
const vm = useFooViewModel();
|
||||
....
|
||||
return(
|
||||
<div>
|
||||
{vm.somethingUseful}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
3. Views are also allowed to accept the view model as a prop, eg:
|
||||
```tsx
|
||||
const FooView: React.FC<IProps> = ({ vm }: IProps) => {
|
||||
....
|
||||
return(
|
||||
<div>
|
||||
{vm.somethingUseful}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
4. Multiple views can share the same view model if necessary.
|
||||
|
||||
### Benefits
|
||||
|
||||
1. MVVM forces a separation of concern i.e we will no longer have large react components that have a lot of state and rendering code mixed together. This improves code readability and makes it easier to introduce changes.
|
||||
2. Introduces the possibility of code reuse. You can reuse an old view model with a new view or vice versa.
|
||||
3. Adding to the point above, in future you could import element-web view models to your project and supply your own views thus creating something similar to the [hydrogen sdk](https://github.com/element-hq/hydrogen-web/blob/master/doc/SDK.md).
|
||||
|
||||
### Example
|
||||
|
||||
We started experimenting with MVVM in the redesigned memberlist, you can see the code [here](https://github.com/vector-im/element-web/blob/develop/src/components/views/rooms/MemberList/MemberListView.tsx).
|
||||
@@ -155,7 +155,7 @@ complete re-branding/private labeling, a more personalised experience can be ach
|
||||
3. `show_once`: Optional. If true then the notice will only be shown once per device.
|
||||
18. `help_url`: The URL to point users to for help with the app, defaults to `https://element.io/help`.
|
||||
19. `help_encryption_url`: The URL to point users to for help with encryption, defaults to `https://element.io/help#encryption`.
|
||||
20. `force_verification`: If true, users must verify new logins (eg. with another device / their recovery key)
|
||||
20. `force_verification`: If true, users must verify new logins (eg. with another device / their security key)
|
||||
|
||||
### `desktop_builds` and `mobile_builds`
|
||||
|
||||
@@ -163,14 +163,14 @@ These two options describe the various availability for the application. When th
|
||||
such as trying to get the user to use an Android app or the desktop app for encrypted search, the config options will be looked
|
||||
at to see if the link should be to somewhere else.
|
||||
|
||||
Starting with `desktop_builds`, the following sub-properties are available:
|
||||
Starting with `desktop_builds`, the following subproperties are available:
|
||||
|
||||
1. `available`: Required. When `true`, the desktop app can be downloaded from somewhere.
|
||||
2. `logo`: Required. A URL to a logo (SVG), intended to be shown at 24x24 pixels.
|
||||
3. `url`: Required. The download URL for the app. This is used as a hyperlink.
|
||||
4. `url_macos`: Optional. Direct link to download macOS desktop app.
|
||||
5. `url_win64`: Optional. Direct link to download Windows x86 64-bit desktop app.
|
||||
6. `url_win64arm`: Optional. Direct link to download Windows ARM 64-bit desktop app.
|
||||
5. `url_win32`: Optional. Direct link to download Windows 32-bit desktop app.
|
||||
6. `url_win64`: Optional. Direct link to download Windows 64-bit desktop app.
|
||||
7. `url_linux`: Optional. Direct link to download Linux desktop app.
|
||||
|
||||
When `desktop_builds` is not specified at all, the app will assume desktop downloads are available from https://element.io
|
||||
|
||||
@@ -66,18 +66,6 @@ on other runtimes may require root privileges. To resolve this, either run the
|
||||
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/`.
|
||||
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.
|
||||
|
||||
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/
|
||||
- /etc/nginx/conf.d/
|
||||
|
||||
The behaviour of the docker image can be customised via the following
|
||||
environment variables:
|
||||
|
||||
|
||||
@@ -112,7 +112,3 @@ Unreliable in encrypted rooms.
|
||||
## Knock rooms (`feature_ask_to_join`) [In Development]
|
||||
|
||||
Enables knock feature for rooms. This allows users to ask to join a room.
|
||||
|
||||
## New room list (`feature_new_room_list`) [In Development]
|
||||
|
||||
Enable the new room list that is currently in development.
|
||||
|
||||
@@ -128,7 +128,7 @@ flowchart TD
|
||||
|
||||
subgraph Deploying
|
||||
D1[\Deploy staging.element.io/]
|
||||
D2[\Check docker build/]
|
||||
D2[\Check dockerhub/]
|
||||
D3[\Deploy app.element.io/]
|
||||
D4[\Check desktop package/]
|
||||
|
||||
@@ -213,10 +213,10 @@ switched back to the version of the dependency from the master branch to not lea
|
||||
# Deploying
|
||||
|
||||
We ship the SDKs to npm, this happens as part of the release process.
|
||||
We ship Element Web to dockerhub, ghcr.io, `*.element.io`, and packages.element.io.
|
||||
We ship Element Web to dockerhub, `*.element.io`, and packages.element.io.
|
||||
We ship Element Desktop to packages.element.io.
|
||||
|
||||
- [ ] Check that element-web has shipped to dockerhub & ghcr.io
|
||||
- [ ] Check that element-web has shipped to dockerhub
|
||||
- [ ] Check that the staging [deployment](https://github.com/element-hq/element-web/actions/workflows/deploy.yml) has completed successfully
|
||||
- [ ] Test staging.element.io
|
||||
|
||||
|
||||
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "element-web",
|
||||
"version": "1.11.95",
|
||||
"version": "1.11.91",
|
||||
"description": "Element: the future of secure communication",
|
||||
"author": "New Vector Ltd.",
|
||||
"repository": {
|
||||
@@ -74,7 +74,7 @@
|
||||
"@types/react-dom": "18.3.5",
|
||||
"oidc-client-ts": "3.1.0",
|
||||
"jwt-decode": "4.0.0",
|
||||
"caniuse-lite": "1.0.30001699",
|
||||
"caniuse-lite": "1.0.30001697",
|
||||
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
|
||||
"wrap-ansi": "npm:wrap-ansi@^7.0.0"
|
||||
},
|
||||
@@ -88,11 +88,11 @@
|
||||
"@matrix-org/emojibase-bindings": "^1.3.4",
|
||||
"@matrix-org/react-sdk-module-api": "^2.4.0",
|
||||
"@matrix-org/spec": "^1.7.0",
|
||||
"@sentry/browser": "^9.0.0",
|
||||
"@sentry/browser": "^8.0.0",
|
||||
"@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-design-tokens": "^3.0.0",
|
||||
"@vector-im/compound-web": "^7.6.1",
|
||||
"@vector-im/matrix-wysiwyg": "2.38.0",
|
||||
"@zxcvbn-ts/core": "^3.0.4",
|
||||
"@zxcvbn-ts/language-common": "^3.0.4",
|
||||
@@ -128,7 +128,7 @@
|
||||
"maplibre-gl": "^5.0.0",
|
||||
"matrix-encrypt-attachment": "^1.0.3",
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
"matrix-js-sdk": "37.1.0",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
|
||||
"matrix-widget-api": "^1.10.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"mime": "^4.0.4",
|
||||
@@ -162,11 +162,9 @@
|
||||
"@babel/core": "^7.12.10",
|
||||
"@babel/eslint-parser": "^7.12.10",
|
||||
"@babel/eslint-plugin": "^7.12.10",
|
||||
"@babel/plugin-proposal-decorators": "^7.25.9",
|
||||
"@babel/plugin-proposal-export-default-from": "^7.12.1",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-class-properties": "^7.12.1",
|
||||
"@babel/plugin-transform-class-static-block": "^7.26.0",
|
||||
"@babel/plugin-transform-logical-assignment-operators": "^7.20.7",
|
||||
"@babel/plugin-transform-nullish-coalescing-operator": "^7.12.1",
|
||||
"@babel/plugin-transform-numeric-separator": "^7.12.7",
|
||||
@@ -276,7 +274,7 @@
|
||||
"postcss-preset-env": "^10.0.0",
|
||||
"postcss-scss": "^4.0.4",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "3.5.1",
|
||||
"prettier": "3.4.2",
|
||||
"process": "^0.11.10",
|
||||
"raw-loader": "^4.0.2",
|
||||
"rimraf": "^6.0.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM mcr.microsoft.com/playwright:v1.50.1-noble
|
||||
FROM mcr.microsoft.com/playwright:v1.49.1-noble
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { registerAccountMas } from "../oidc";
|
||||
import { isDendrite } from "../../plugins/homeserver/dendrite";
|
||||
import { TestClientServerAPI } from "../csAPI";
|
||||
import { masHomeserver } from "../../plugins/homeserver/synapse/masHomeserver.ts";
|
||||
import { checkDeviceIsConnectedKeyBackup } from "./utils";
|
||||
|
||||
// These tests register an account with MAS because then we go through the "normal" registration flow
|
||||
// and crypto gets set up. Using the 'user' fixture create a user and synthesizes an existing login,
|
||||
@@ -25,11 +24,8 @@ test.describe("Encryption state after registration", () => {
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await registerAccountMas(page, mailpitClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!");
|
||||
|
||||
// Wait for the ui to load
|
||||
await expect(page.locator(".mx_MatrixChat")).toBeVisible();
|
||||
|
||||
// Recovery is not set up yet
|
||||
await checkDeviceIsConnectedKeyBackup(app, "1", true, false);
|
||||
await app.settings.openUserSettings("Security & Privacy");
|
||||
await expect(page.getByText("This session is backing up your keys.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("user is prompted to set up recovery", async ({ page, mailpitClient, app }, testInfo) => {
|
||||
|
||||
@@ -58,8 +58,8 @@ test.describe("Backups", () => {
|
||||
|
||||
// Create another
|
||||
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Recovery Key" })).toBeVisible();
|
||||
await currentDialogLocator.getByLabel("Recovery Key").fill(securityKey);
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Security Key" })).toBeVisible();
|
||||
await currentDialogLocator.getByLabel("Security Key").fill(securityKey);
|
||||
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
|
||||
// Should be successful
|
||||
@@ -90,8 +90,8 @@ test.describe("Backups", () => {
|
||||
|
||||
// Try to create another
|
||||
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Recovery Key" })).toBeVisible();
|
||||
// But cancel the recovery key dialog, to simulate not having the secret storage passphrase
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Security Key" })).toBeVisible();
|
||||
// But cancel the security key dialog, to simulate not having the secret storage passphrase
|
||||
await currentDialogLocator.getByTestId("dialog-cancel-button").click();
|
||||
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Starting backup…" })).toBeVisible();
|
||||
|
||||
@@ -28,7 +28,7 @@ const checkDMRoom = async (page: Page) => {
|
||||
};
|
||||
|
||||
const startDMWithBob = async (page: Page, bob: Bot) => {
|
||||
await page.locator(".mx_LegacyRoomList").getByRole("button", { name: "Start chat" }).click();
|
||||
await page.locator(".mx_RoomList").getByRole("button", { name: "Start chat" }).click();
|
||||
await page.getByTestId("invite-dialog-input").fill(bob.credentials.userId);
|
||||
await page.locator(".mx_InviteDialog_tile_nameStack_name").getByText("Bob").click();
|
||||
await expect(
|
||||
@@ -186,7 +186,7 @@ test.describe("Cryptography", function () {
|
||||
await page.getByRole("button", { name: "Clear cross-signing keys" }).click();
|
||||
|
||||
// Enter the 4S key
|
||||
await page.getByPlaceholder("Recovery Key").fill(secretStorageKey);
|
||||
await page.getByPlaceholder("Security Key").fill(secretStorageKey);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Enter the password
|
||||
|
||||
@@ -6,14 +6,21 @@ 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 Locator, type Page } from "@playwright/test";
|
||||
|
||||
import { test, expect } from "../../element-web-test";
|
||||
import { viewRoomSummaryByName } from "../right-panel/utils";
|
||||
import { isDendrite } from "../../plugins/homeserver/dendrite";
|
||||
import { completeCreateSecretStorageDialog, createBot, logIntoElement } from "./utils.ts";
|
||||
import { type Client } from "../../pages/client.ts";
|
||||
import { type ElementAppPage } from "../../pages/ElementAppPage.ts";
|
||||
|
||||
const ROOM_NAME = "Test room";
|
||||
const NAME = "Alice";
|
||||
|
||||
function getMemberTileByName(page: Page, name: string): Locator {
|
||||
return page.locator(`.mx_MemberTileView, [title="${name}"]`);
|
||||
}
|
||||
|
||||
test.use({
|
||||
displayName: NAME,
|
||||
synapseConfig: {
|
||||
@@ -22,6 +29,18 @@ test.use({
|
||||
msc3814_enabled: true,
|
||||
},
|
||||
},
|
||||
config: async ({ config, context }, use) => {
|
||||
const wellKnown = {
|
||||
...config.default_server_config,
|
||||
"org.matrix.msc3814": true,
|
||||
};
|
||||
|
||||
await context.route("https://localhost/.well-known/matrix/client", async (route) => {
|
||||
await route.fulfill({ json: wellKnown });
|
||||
});
|
||||
|
||||
await use(config);
|
||||
},
|
||||
});
|
||||
|
||||
test.describe("Dehydration", () => {
|
||||
@@ -38,40 +57,36 @@ test.describe("Dehydration", () => {
|
||||
|
||||
await completeCreateSecretStorageDialog(page);
|
||||
|
||||
await expectDehydratedDeviceEnabled(app);
|
||||
// Open the settings again
|
||||
await app.settings.openUserSettings("Security & Privacy");
|
||||
|
||||
// The Security tab should indicate that there is a dehydrated device present
|
||||
await expect(securityTab.getByText("Offline device enabled")).toBeVisible();
|
||||
|
||||
await app.settings.closeDialog();
|
||||
|
||||
// the dehydrated device gets created with the name "Dehydrated
|
||||
// device". We want to make sure that it is not visible as a normal
|
||||
// device.
|
||||
const sessionsTab = await app.settings.openUserSettings("Sessions");
|
||||
await expect(sessionsTab.getByText("Dehydrated device")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("'Set up recovery' creates dehydrated device", async ({ app, credentials, page }) => {
|
||||
await logIntoElement(page, credentials);
|
||||
|
||||
const settingsDialogLocator = await app.settings.openUserSettings("Encryption");
|
||||
await settingsDialogLocator.getByRole("button", { name: "Set up recovery" }).click();
|
||||
|
||||
// First it displays an informative panel about the recovery key
|
||||
await expect(settingsDialogLocator.getByRole("heading", { name: "Set up recovery" })).toBeVisible();
|
||||
await settingsDialogLocator.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Next, it displays the new recovery key. We click on the copy button.
|
||||
await expect(settingsDialogLocator.getByText("Save your recovery key somewhere safe")).toBeVisible();
|
||||
await settingsDialogLocator.getByRole("button", { name: "Copy" }).click();
|
||||
const recoveryKey = await app.getClipboard();
|
||||
await settingsDialogLocator.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await expect(
|
||||
settingsDialogLocator.getByText("Enter your recovery key to confirm", { exact: true }),
|
||||
).toBeVisible();
|
||||
await settingsDialogLocator.getByRole("textbox").fill(recoveryKey);
|
||||
await settingsDialogLocator.getByRole("button", { name: "Finish set up" }).click();
|
||||
|
||||
await app.settings.closeDialog();
|
||||
|
||||
await expectDehydratedDeviceEnabled(app);
|
||||
// now check that the user info right-panel shows the dehydrated device
|
||||
// as a feature rather than as a normal device
|
||||
await app.client.createRoom({ name: ROOM_NAME });
|
||||
|
||||
await viewRoomSummaryByName(page, app, ROOM_NAME);
|
||||
|
||||
await page.locator(".mx_RightPanel").getByRole("menuitem", { name: "People" }).click();
|
||||
await expect(page.locator(".mx_MemberListView")).toBeVisible();
|
||||
|
||||
await getMemberTileByName(page, NAME).click();
|
||||
await page.locator(".mx_UserInfo_devices .mx_UserInfo_expand").click();
|
||||
|
||||
await expect(page.locator(".mx_UserInfo_devices").getByText("Offline device enabled")).toBeVisible();
|
||||
await expect(page.locator(".mx_UserInfo_devices").getByText("Dehydrated device")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("Reset recovery key during login re-creates dehydrated device", async ({
|
||||
@@ -104,40 +119,6 @@ test.describe("Dehydration", () => {
|
||||
expect(dehydratedDeviceIds.length).toBe(1);
|
||||
expect(dehydratedDeviceIds[0]).not.toEqual(initialDehydratedDeviceIds[0]);
|
||||
});
|
||||
|
||||
test("'Reset cryptographic identity' removes dehydrated device", async ({ page, homeserver, app, credentials }) => {
|
||||
await logIntoElement(page, credentials);
|
||||
|
||||
// Create a dehydrated device by setting up recovery (see "'Set up
|
||||
// recovery' creates dehydrated device" test above)
|
||||
const settingsDialogLocator = await app.settings.openUserSettings("Encryption");
|
||||
await settingsDialogLocator.getByRole("button", { name: "Set up recovery" }).click();
|
||||
|
||||
// First it displays an informative panel about the recovery key
|
||||
await expect(settingsDialogLocator.getByRole("heading", { name: "Set up recovery" })).toBeVisible();
|
||||
await settingsDialogLocator.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Next, it displays the new recovery key. We click on the copy button.
|
||||
await expect(settingsDialogLocator.getByText("Save your recovery key somewhere safe")).toBeVisible();
|
||||
await settingsDialogLocator.getByRole("button", { name: "Copy" }).click();
|
||||
const recoveryKey = await app.getClipboard();
|
||||
await settingsDialogLocator.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await expect(
|
||||
settingsDialogLocator.getByText("Enter your recovery key to confirm", { exact: true }),
|
||||
).toBeVisible();
|
||||
await settingsDialogLocator.getByRole("textbox").fill(recoveryKey);
|
||||
await settingsDialogLocator.getByRole("button", { name: "Finish set up" }).click();
|
||||
|
||||
await expectDehydratedDeviceEnabled(app);
|
||||
|
||||
// After recovery is set up, we reset our cryptographic identity, which
|
||||
// should drop the dehydrated device.
|
||||
await settingsDialogLocator.getByRole("button", { name: "Reset cryptographic identity" }).click();
|
||||
await settingsDialogLocator.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await expectDehydratedDeviceDisabled(app);
|
||||
});
|
||||
});
|
||||
|
||||
async function getDehydratedDeviceIds(client: Client): Promise<string[]> {
|
||||
@@ -153,29 +134,3 @@ async function getDehydratedDeviceIds(client: Client): Promise<string[]> {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Wait for our user to have a dehydrated device */
|
||||
async function expectDehydratedDeviceEnabled(app: ElementAppPage): Promise<void> {
|
||||
// It might be nice to do this via the UI, but currently this info is not exposed via the UI.
|
||||
//
|
||||
// Note we might have to wait for the device list to be refreshed, so we wrap in `expect.poll`.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const dehydratedDeviceIds = await getDehydratedDeviceIds(app.client);
|
||||
return dehydratedDeviceIds.length;
|
||||
})
|
||||
.toEqual(1);
|
||||
}
|
||||
|
||||
/** Wait for our user to not have a dehydrated device */
|
||||
async function expectDehydratedDeviceDisabled(app: ElementAppPage): Promise<void> {
|
||||
// It might be nice to do this via the UI, but currently this info is not exposed via the UI.
|
||||
//
|
||||
// Note we might have to wait for the device list to be refreshed, so we wrap in `expect.poll`.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const dehydratedDeviceIds = await getDehydratedDeviceIds(app.client);
|
||||
return dehydratedDeviceIds.length;
|
||||
})
|
||||
.toEqual(0);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
waitForVerificationRequest,
|
||||
} from "./utils";
|
||||
import { type Bot } from "../../pages/bot";
|
||||
import { Toasts } from "../../pages/toasts.ts";
|
||||
|
||||
test.describe("Device verification", { tag: "@no-webkit" }, () => {
|
||||
let aliceBotClient: Bot;
|
||||
@@ -73,51 +72,6 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
|
||||
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, false);
|
||||
});
|
||||
|
||||
// Regression test for https://github.com/element-hq/element-web/issues/29110
|
||||
test("No toast after verification, even if the secrets take a while to arrive", async ({ page, credentials }) => {
|
||||
// Before we log in, the bot creates an encrypted room, so that we can test the toast behaviour that only happens
|
||||
// when we are in an encrypted room.
|
||||
await aliceBotClient.createRoom({
|
||||
initial_state: [
|
||||
{
|
||||
type: "m.room.encryption",
|
||||
state_key: "",
|
||||
content: { algorithm: "m.megolm.v1.aes-sha2" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// In order to simulate a real environment more accurately, we need to slow down the arrival of the
|
||||
// `m.secret.send` to-device messages. That's slightly tricky to do directly, so instead we delay the *outgoing*
|
||||
// `m.secret.request` messages.
|
||||
await page.route("**/_matrix/client/v3/sendToDevice/m.secret.request/**", async (route) => {
|
||||
await route.fulfill({ json: {} });
|
||||
await new Promise((f) => setTimeout(f, 1000));
|
||||
await route.fetch();
|
||||
});
|
||||
|
||||
await logIntoElement(page, credentials);
|
||||
|
||||
// Launch the verification request between alice and the bot
|
||||
const verificationRequest = await initiateAliceVerificationRequest(page);
|
||||
|
||||
// Handle emoji SAS verification
|
||||
const infoDialog = page.locator(".mx_InfoDialog");
|
||||
// the bot chooses to do an emoji verification
|
||||
const verifier = await verificationRequest.evaluateHandle((request) => request.startVerification("m.sas.v1"));
|
||||
|
||||
// Handle emoji request and check that emojis are matching
|
||||
await doTwoWaySasVerification(page, verifier);
|
||||
|
||||
await infoDialog.getByRole("button", { name: "They match" }).click();
|
||||
await infoDialog.getByRole("button", { name: "Got it" }).click();
|
||||
|
||||
// There should be no toast (other than the notifications one)
|
||||
const toasts = new Toasts(page);
|
||||
await toasts.rejectToast("Notifications");
|
||||
await toasts.assertNoToasts();
|
||||
});
|
||||
|
||||
test("Verify device with QR code during login", async ({ page, app, credentials, homeserver }) => {
|
||||
// A mode 0x02 verification: "self-verifying in which the current device does not yet trust the master key"
|
||||
await logIntoElement(page, credentials);
|
||||
@@ -165,7 +119,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
|
||||
await logIntoElement(page, credentials);
|
||||
|
||||
// Select the security phrase
|
||||
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key or Phrase" }).click();
|
||||
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key or Phrase" }).click();
|
||||
|
||||
// Fill the passphrase
|
||||
const dialog = page.locator(".mx_Dialog");
|
||||
@@ -182,15 +136,15 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
|
||||
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
|
||||
});
|
||||
|
||||
test("Verify device with Recovery Key during login", async ({ page, app, credentials, homeserver }) => {
|
||||
test("Verify device with Security Key during login", async ({ page, app, credentials, homeserver }) => {
|
||||
await logIntoElement(page, credentials);
|
||||
|
||||
// Select the security phrase
|
||||
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key or Phrase" }).click();
|
||||
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key or Phrase" }).click();
|
||||
|
||||
// Fill the recovery key
|
||||
// Fill the security key
|
||||
const dialog = page.locator(".mx_Dialog");
|
||||
await dialog.getByRole("button", { name: "use your Recovery Key" }).click();
|
||||
await dialog.getByRole("button", { name: "use your Security Key" }).click();
|
||||
const aliceRecoveryKey = await aliceBotClient.getRecoveryKey();
|
||||
await dialog.locator("#mx_securityKey").fill(aliceRecoveryKey.encodedPrivateKey);
|
||||
await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
logIntoElement,
|
||||
logOutOfElement,
|
||||
verify,
|
||||
waitForDevices,
|
||||
} from "./utils";
|
||||
import { bootstrapCrossSigningForClient } from "../../pages/client.ts";
|
||||
import { type ElementAppPage } from "../../pages/ElementAppPage.ts";
|
||||
@@ -145,8 +144,25 @@ test.describe("Cryptography", function () {
|
||||
// bob deletes his second device
|
||||
await bobSecondDevice.evaluate((cli) => cli.logout(true));
|
||||
|
||||
// wait for the logout to propagate.
|
||||
await waitForDevices(app, bob.credentials.userId, 1);
|
||||
// wait for the logout to propagate. Workaround for https://github.com/vector-im/element-web/issues/26263 by repeatedly closing and reopening Bob's user info.
|
||||
async function awaitOneDevice(iterations = 1) {
|
||||
const rightPanel = page.locator(".mx_RightPanel");
|
||||
await rightPanel.getByTestId("base-card-back-button").click();
|
||||
await rightPanel.getByText("Bob").click();
|
||||
const sessionCountText = await rightPanel
|
||||
.locator(".mx_UserInfo_devices")
|
||||
.getByText(" session", { exact: false })
|
||||
.textContent();
|
||||
// cf https://github.com/vector-im/element-web/issues/26279: Element-R uses the wrong text here
|
||||
if (sessionCountText != "1 session" && sessionCountText != "1 verified session") {
|
||||
if (iterations >= 10) {
|
||||
throw new Error(`Bob still has ${sessionCountText} after 10 iterations`);
|
||||
}
|
||||
await awaitOneDevice(iterations + 1);
|
||||
}
|
||||
}
|
||||
|
||||
await awaitOneDevice();
|
||||
|
||||
// close and reopen the room, to get the shield to update.
|
||||
await app.viewRoomByName("Bob");
|
||||
@@ -269,7 +285,11 @@ test.describe("Cryptography", function () {
|
||||
// Workaround for https://github.com/element-hq/element-web/issues/28640:
|
||||
// make sure that Alice has seen Bob's identity before she goes offline. We do this by opening
|
||||
// his user info.
|
||||
await waitForDevices(app, bob.credentials.userId, 1);
|
||||
await app.toggleRoomInfoPanel();
|
||||
const rightPanel = page.locator(".mx_RightPanel");
|
||||
await rightPanel.getByRole("menuitem", { name: "People" }).click();
|
||||
await rightPanel.getByRole("button", { name: bob.credentials!.userId }).click();
|
||||
await expect(rightPanel.locator(".mx_UserInfo_devices")).toContainText("1 session");
|
||||
|
||||
// Our app is blocked from syncing while Bob sends his messages.
|
||||
await app.client.network.goOffline();
|
||||
|
||||
@@ -35,7 +35,7 @@ test.describe("Key storage out of sync toast", () => {
|
||||
|
||||
await page.getByRole("button", { name: "Enter recovery key" }).click();
|
||||
|
||||
await page.getByRole("textbox", { name: "Recovery Key" }).fill(recoveryKey.encodedPrivateKey);
|
||||
await page.getByRole("textbox", { name: "Security key" }).fill(recoveryKey.encodedPrivateKey);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Enter recovery key" })).not.toBeVisible();
|
||||
|
||||
@@ -8,8 +8,9 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import { type Preset, type Visibility } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../../element-web-test";
|
||||
import { doTwoWaySasVerification, awaitVerifier, waitForDevices } from "./utils";
|
||||
import { doTwoWaySasVerification, awaitVerifier } from "./utils";
|
||||
import { type Client } from "../../pages/client";
|
||||
|
||||
test.describe("User verification", () => {
|
||||
@@ -32,17 +33,13 @@ test.describe("User verification", () => {
|
||||
});
|
||||
|
||||
test("can receive a verification request when there is no existing DM", async ({
|
||||
app,
|
||||
page,
|
||||
bot: bob,
|
||||
user: aliceCredentials,
|
||||
toasts,
|
||||
room: { roomId: dmRoomId },
|
||||
}) => {
|
||||
await waitForDevices(app, bob.credentials.userId, 1);
|
||||
await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible();
|
||||
const avatar = page.getByRole("button", { name: "Avatar" });
|
||||
await avatar.click();
|
||||
await waitForDeviceKeys(page);
|
||||
|
||||
// once Alice has joined, Bob starts the verification
|
||||
const bobVerificationRequest = await bob.evaluateHandle(
|
||||
@@ -87,17 +84,13 @@ test.describe("User verification", () => {
|
||||
});
|
||||
|
||||
test("can abort emoji verification when emoji mismatch", async ({
|
||||
app,
|
||||
page,
|
||||
bot: bob,
|
||||
user: aliceCredentials,
|
||||
toasts,
|
||||
room: { roomId: dmRoomId },
|
||||
}) => {
|
||||
await waitForDevices(app, bob.credentials.userId, 1);
|
||||
await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible();
|
||||
const avatar = page.getByRole("button", { name: "Avatar" });
|
||||
await avatar.click();
|
||||
await waitForDeviceKeys(page);
|
||||
|
||||
// once Alice has joined, Bob starts the verification
|
||||
const bobVerificationRequest = await bob.evaluateHandle(
|
||||
@@ -161,3 +154,15 @@ async function createDMRoom(client: Client, userId: string): Promise<string> {
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until we get the other user's device keys.
|
||||
* In newer rust-crypto versions, the verification request will be ignored if we
|
||||
* don't have the sender's device keys.
|
||||
*/
|
||||
async function waitForDeviceKeys(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible();
|
||||
const avatar = await page.getByRole("button", { name: "Avatar" });
|
||||
await avatar.click();
|
||||
await expect(page.getByText("1 session")).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -145,13 +145,11 @@ export async function checkDeviceIsCrossSigned(app: ElementAppPage): Promise<voi
|
||||
* @param app -` ElementAppPage` wrapper for the playwright `Page`.
|
||||
* @param expectedBackupVersion - the version of the backup we expect to be connected to.
|
||||
* @param checkBackupPrivateKeyInCache - whether to check that the backup decryption key is cached locally
|
||||
* @param checkBackupKeyIn4S - whether to check that the backup key is stored in 4S
|
||||
*/
|
||||
export async function checkDeviceIsConnectedKeyBackup(
|
||||
app: ElementAppPage,
|
||||
expectedBackupVersion: string,
|
||||
checkBackupPrivateKeyInCache: boolean,
|
||||
checkBackupKeyIn4S: boolean = true,
|
||||
): Promise<void> {
|
||||
// Sanity check the given backup version: if it's null, something went wrong earlier in the test.
|
||||
if (!expectedBackupVersion) {
|
||||
@@ -194,7 +192,7 @@ export async function checkDeviceIsConnectedKeyBackup(
|
||||
// The active backup version is as expected
|
||||
expect(activeBackupVersion).toBe(expectedBackupVersion);
|
||||
// The backup key is stored in 4S
|
||||
if (checkBackupKeyIn4S) expect(backupKeyIn4S).toBe(true);
|
||||
expect(backupKeyIn4S).toBe(true);
|
||||
|
||||
if (checkBackupPrivateKeyInCache) {
|
||||
// The backup key is available locally
|
||||
@@ -218,13 +216,13 @@ export async function logIntoElement(page: Page, credentials: Credentials, secur
|
||||
|
||||
// if a securityKey was given, verify the new device
|
||||
if (securityKey !== undefined) {
|
||||
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key" }).click();
|
||||
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key" }).click();
|
||||
|
||||
const useSecurityKey = page.locator(".mx_Dialog").getByRole("button", { name: "use your Recovery Key" });
|
||||
const useSecurityKey = page.locator(".mx_Dialog").getByRole("button", { name: "use your Security Key" });
|
||||
if (await useSecurityKey.isVisible()) {
|
||||
await useSecurityKey.click();
|
||||
}
|
||||
// Fill in the recovery key
|
||||
// Fill in the security key
|
||||
await page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey);
|
||||
await page.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
|
||||
await page.getByRole("button", { name: "Done" }).click();
|
||||
@@ -251,15 +249,15 @@ export async function logOutOfElement(page: Page, discardKeys: boolean = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the encryption settings, and verify the current session using the recovery key.
|
||||
* Open the encryption settings, and verify the current session using the security key.
|
||||
*
|
||||
* @param app - `ElementAppPage` wrapper for the playwright `Page`.
|
||||
* @param securityKey - The recovery key (i.e., 4S key), set up during a previous session.
|
||||
* @param securityKey - The security key (i.e., 4S key), set up during a previous session.
|
||||
*/
|
||||
export async function verifySession(app: ElementAppPage, securityKey: string) {
|
||||
const settings = await app.settings.openUserSettings("Encryption");
|
||||
await settings.getByRole("button", { name: "Verify this device" }).click();
|
||||
await app.page.getByRole("button", { name: "Verify with Recovery Key" }).click();
|
||||
await app.page.getByRole("button", { name: "Verify with Security Key" }).click();
|
||||
await app.page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey);
|
||||
await app.page.getByRole("button", { name: "Continue", disabled: false }).click();
|
||||
await app.page.getByRole("button", { name: "Done" }).click();
|
||||
@@ -293,7 +291,7 @@ export async function doTwoWaySasVerification(page: Page, verifier: JSHandle<Ver
|
||||
*
|
||||
* Assumes that the current device has been cross-signed (which means that we skip a step where we set it up).
|
||||
*
|
||||
* Returns the recovery key
|
||||
* Returns the security key
|
||||
*/
|
||||
export async function enableKeyBackup(app: ElementAppPage): Promise<string> {
|
||||
await app.settings.openUserSettings("Security & Privacy");
|
||||
@@ -321,9 +319,9 @@ export async function completeCreateSecretStorageDialog(
|
||||
const currentDialogLocator = page.locator(".mx_Dialog");
|
||||
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible();
|
||||
// "Generate a Recovery Key" is selected by default
|
||||
// "Generate a Security Key" is selected by default
|
||||
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Save your Recovery Key" })).toBeVisible();
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible();
|
||||
await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click();
|
||||
// copy the recovery key to use it later
|
||||
const recoveryKey = await page.evaluate(() => navigator.clipboard.readText());
|
||||
@@ -347,7 +345,7 @@ export async function completeCreateSecretStorageDialog(
|
||||
}
|
||||
|
||||
/**
|
||||
* Click on copy and continue buttons to dismiss the recovery key dialog
|
||||
* Click on copy and continue buttons to dismiss the security key dialog
|
||||
*/
|
||||
export async function copyAndContinue(page: Page) {
|
||||
await page.getByRole("button", { name: "Copy" }).click();
|
||||
@@ -504,31 +502,3 @@ export async function deleteCachedSecrets(page: Page) {
|
||||
});
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the given user has a given number of devices.
|
||||
* This function will check the device keys ten times and if
|
||||
* the expected number of devices were not found by then, an
|
||||
* error is thrown.
|
||||
*/
|
||||
export async function waitForDevices(
|
||||
app: ElementAppPage,
|
||||
userId: string,
|
||||
expectedNumberOfDevices: number,
|
||||
): Promise<void> {
|
||||
const result = await app.client.evaluate(
|
||||
async (cli, { userId, expectedNumberOfDevices }) => {
|
||||
for (let i = 0; i < 10; ++i) {
|
||||
const userDeviceMap = await cli.getCrypto()?.getUserDeviceInfo([userId], true);
|
||||
const deviceMap = userDeviceMap?.get(userId);
|
||||
if (deviceMap.size === expectedNumberOfDevices) return true;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ userId, expectedNumberOfDevices },
|
||||
);
|
||||
if (!result) {
|
||||
throw new Error(`User ${userId} did not have ${expectedNumberOfDevices} devices within ten iterations!`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,21 +6,22 @@ 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 CredentialsWithDisplayName, expect, test as base } from "../../element-web-test";
|
||||
import { expect, test as base } from "../../element-web-test";
|
||||
import { selectHomeserver } from "../utils";
|
||||
import { emailHomeserver } from "../../plugins/homeserver/synapse/emailHomeserver.ts";
|
||||
import { isDendrite } from "../../plugins/homeserver/dendrite";
|
||||
import { type Credentials } from "../../plugins/homeserver";
|
||||
|
||||
const email = "user@nowhere.dummy";
|
||||
|
||||
const test = base.extend({
|
||||
const test = base.extend<{ credentials: Pick<Credentials, "username" | "password"> }>({
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
credentials: async ({}, use, testInfo) => {
|
||||
await use({
|
||||
username: `user_${testInfo.testId}`,
|
||||
// this has to be password-like enough to please zxcvbn. Needless to say it's just from pwgen.
|
||||
password: "oETo7MPf0o",
|
||||
} as CredentialsWithDisplayName);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ test.describe("Invite dialog", function () {
|
||||
"should support inviting a user to Direct Messages",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, app, user, bot }) => {
|
||||
await page.locator(".mx_LegacyRoomList").getByRole("button", { name: "Start chat" }).click();
|
||||
await page.locator(".mx_RoomList").getByRole("button", { name: "Start chat" }).click();
|
||||
|
||||
const other = page.locator(".mx_InviteDialog_other");
|
||||
// Assert that the header is rendered
|
||||
|
||||
@@ -1,87 +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 { test, expect } from "../../../element-web-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
test.describe("Header section of the room list", () => {
|
||||
test.use({
|
||||
labsFlags: ["feature_new_room_list"],
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the header section of the room list
|
||||
* @param page
|
||||
*/
|
||||
function getHeaderSection(page: Page) {
|
||||
return page.getByTestId("room-list-header");
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, app, user }) => {
|
||||
// The notification toast is displayed above the search section
|
||||
await app.closeNotificationToast();
|
||||
});
|
||||
|
||||
test("should render the header section", { tag: "@screenshot" }, async ({ page, app, user }) => {
|
||||
const roomListHeader = getHeaderSection(page);
|
||||
await expect(roomListHeader).toMatchScreenshot("room-list-header.png");
|
||||
|
||||
const composeMenu = roomListHeader.getByRole("button", { name: "Add" });
|
||||
await composeMenu.click();
|
||||
|
||||
await expect(page.getByRole("menu")).toMatchScreenshot("room-list-header-compose-menu.png");
|
||||
|
||||
// New message should open the direct messages dialog
|
||||
await page.getByRole("menuitem", { name: "New message" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Direct Messages" })).toBeVisible();
|
||||
await app.closeDialog();
|
||||
|
||||
// New room should open the room creation dialog
|
||||
await composeMenu.click();
|
||||
await page.getByRole("menuitem", { name: "New room" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Create a private room" })).toBeVisible();
|
||||
await app.closeDialog();
|
||||
});
|
||||
|
||||
test("should render the header section for a space", { tag: "@screenshot" }, async ({ page, app, user }) => {
|
||||
await app.client.createSpace({ name: "MySpace" });
|
||||
await page.getByRole("button", { name: "MySpace" }).click();
|
||||
|
||||
const roomListHeader = getHeaderSection(page);
|
||||
await expect(roomListHeader).toMatchScreenshot("room-list-space-header.png");
|
||||
|
||||
await expect(roomListHeader.getByRole("heading", { name: "MySpace" })).toBeVisible();
|
||||
await expect(roomListHeader.getByRole("button", { name: "Add" })).toBeVisible();
|
||||
|
||||
const spaceMenu = roomListHeader.getByRole("button", { name: "Open space menu" });
|
||||
await spaceMenu.click();
|
||||
|
||||
await expect(page.getByRole("menu")).toMatchScreenshot("room-list-header-space-menu.png");
|
||||
|
||||
// It should open the space home
|
||||
await page.getByRole("menuitem", { name: "Space home" }).click();
|
||||
await expect(page.getByRole("main").getByRole("heading", { name: "MySpace" })).toBeVisible();
|
||||
|
||||
// It should open the invite dialog
|
||||
await spaceMenu.click();
|
||||
await page.getByRole("menuitem", { name: "Invite" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Invite to MySpace" })).toBeVisible();
|
||||
await app.closeDialog();
|
||||
|
||||
// It should open the space preferences
|
||||
await spaceMenu.click();
|
||||
await page.getByRole("menuitem", { name: "Preferences" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Preferences" })).toBeVisible();
|
||||
await app.closeDialog();
|
||||
|
||||
// It should open the space settings
|
||||
await spaceMenu.click();
|
||||
await page.getByRole("menuitem", { name: "Space Settings" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
|
||||
await app.closeDialog();
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Page } from "@playwright/test";
|
||||
|
||||
import { test, expect } from "../../../element-web-test";
|
||||
|
||||
test.describe("Room list panel", () => {
|
||||
test.use({
|
||||
labsFlags: ["feature_new_room_list"],
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the room list view
|
||||
* @param page
|
||||
*/
|
||||
function getRoomListView(page: Page) {
|
||||
return page.getByTestId("room-list-panel");
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, app, user }) => {
|
||||
// The notification toast is displayed above the search section
|
||||
await app.closeNotificationToast();
|
||||
});
|
||||
|
||||
test("should render the room list panel", { tag: "@screenshot" }, async ({ page, app, user }) => {
|
||||
const roomListView = getRoomListView(page);
|
||||
await expect(roomListView).toMatchScreenshot("room-list-panel.png");
|
||||
});
|
||||
});
|
||||
@@ -1,53 +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 Page } from "@playwright/test";
|
||||
|
||||
import { test, expect } from "../../../element-web-test";
|
||||
|
||||
test.describe("Search section of the room list", () => {
|
||||
test.use({
|
||||
labsFlags: ["feature_new_room_list"],
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the search section of the room list
|
||||
* @param page
|
||||
*/
|
||||
function getSearchSection(page: Page) {
|
||||
return page.getByRole("search");
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, app, user }) => {
|
||||
// The notification toast is displayed above the search section
|
||||
await app.closeNotificationToast();
|
||||
});
|
||||
|
||||
test("should render the search section", { tag: "@screenshot" }, async ({ page, app, user }) => {
|
||||
const searchSection = getSearchSection(page);
|
||||
// exact=false to ignore the shortcut which is related to the OS
|
||||
await expect(searchSection.getByRole("button", { name: "Search", exact: false })).toBeVisible();
|
||||
await expect(searchSection).toMatchScreenshot("search-section.png");
|
||||
});
|
||||
|
||||
test("should open the spotlight when the search button is clicked", async ({ page, app, user }) => {
|
||||
const searchSection = getSearchSection(page);
|
||||
await searchSection.getByRole("button", { name: "Search", exact: false }).click();
|
||||
// The spotlight should be displayed
|
||||
await expect(page.getByRole("dialog", { name: "Search Dialog" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("should open the room directory when the search button is clicked", async ({ page, app, user }) => {
|
||||
const searchSection = getSearchSection(page);
|
||||
await searchSection.getByRole("button", { name: "Explore rooms" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Search Dialog" });
|
||||
// The room directory should be displayed
|
||||
await expect(dialog).toBeVisible();
|
||||
// The public room filter should be displayed
|
||||
await expect(dialog.getByText("Public rooms")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -57,8 +57,8 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => {
|
||||
await util.openThread("ThreadRoot");
|
||||
|
||||
// Then the thread root is marked as read in the main timeline,
|
||||
// 30 remaining messages are unread - 6 messages are displayed under the thread root
|
||||
await util.assertUnread(room2, 30 - 6);
|
||||
// 30 remaining messages are unread - 7 messages are displayed under the thread root
|
||||
await util.assertUnread(room2, 30 - 7);
|
||||
});
|
||||
|
||||
test("Creating a new thread based on a reply makes the room unread", async ({
|
||||
|
||||
@@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Page } from "@playwright/test";
|
||||
import { type Visibility } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { test, expect } from "../../element-web-test";
|
||||
import { type ElementAppPage } from "../../pages/ElementAppPage";
|
||||
@@ -86,15 +85,6 @@ test.describe("Room Header", () => {
|
||||
await expect(header).toMatchScreenshot("room-header-long-name.png");
|
||||
},
|
||||
);
|
||||
|
||||
test("should render room header icon correctly", { tag: "@screenshot" }, async ({ page, app, user }) => {
|
||||
await app.client.createRoom({ name: "Test Room", visibility: "public" as Visibility });
|
||||
await app.viewRoomByName("Test Room");
|
||||
|
||||
const header = page.locator(".mx_RoomHeader");
|
||||
|
||||
await expect(header).toMatchScreenshot("room-header-with-icon.png");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("with a video room", () => {
|
||||
|
||||
@@ -111,4 +111,21 @@ test.describe("Encryption tab", () => {
|
||||
// The user is prompted to reset their identity
|
||||
await expect(dialog.getByText("Forgot your recovery key? You’ll 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");
|
||||
|
||||
await page.getByRole("button", { name: "Delete key storage" }).click();
|
||||
|
||||
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@ class Helpers {
|
||||
*/
|
||||
async verifyDevice(recoveryKey: GeneratedSecretStorageKey) {
|
||||
// Select the security phrase
|
||||
await this.page.getByRole("button", { name: "Verify with Recovery Key" }).click();
|
||||
await this.page.getByRole("button", { name: "Verify with Security Key" }).click();
|
||||
await this.enterRecoveryKey(recoveryKey);
|
||||
await this.page.getByRole("button", { name: "Done" }).click();
|
||||
}
|
||||
@@ -91,7 +91,7 @@ class Helpers {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the recovery key from the clipboard and fill in the input field
|
||||
* Get the security key from the clipboard and fill in the input field
|
||||
* Then click on the finish button
|
||||
* @param title - The title of the dialog
|
||||
* @param confirmButtonLabel - The label of the confirm button
|
||||
|
||||
@@ -25,14 +25,18 @@ test.describe("Security user settings tab", () => {
|
||||
},
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page, app, user }) => {
|
||||
test.beforeEach(async ({ page, user }) => {
|
||||
// Dismiss "Notification" toast
|
||||
await app.closeNotificationToast();
|
||||
await page
|
||||
.locator(".mx_Toast_toast", { hasText: "Notifications" })
|
||||
.getByRole("button", { name: "Dismiss" })
|
||||
.click();
|
||||
|
||||
await page.locator(".mx_Toast_buttons").getByRole("button", { name: "Yes" }).click(); // Allow analytics
|
||||
});
|
||||
|
||||
test.describe("AnalyticsLearnMoreDialog", () => {
|
||||
test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page, user }) => {
|
||||
test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page }) => {
|
||||
const tab = await app.settings.openUserSettings("Security");
|
||||
await tab.getByRole("button", { name: "Learn more" }).click();
|
||||
await expect(page.locator(".mx_AnalyticsLearnMoreDialog_wrapper .mx_Dialog")).toMatchScreenshot(
|
||||
@@ -41,57 +45,16 @@ test.describe("Security user settings tab", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("should be able to set an ID server", async ({ app, context, user, page }) => {
|
||||
test("should contain section to set ID server", async ({ app }) => {
|
||||
const tab = await app.settings.openUserSettings("Security");
|
||||
|
||||
await context.route("https://identity.example.org/_matrix/identity/v2", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
json: {},
|
||||
});
|
||||
});
|
||||
await context.route("https://identity.example.org/_matrix/identity/v2/account/register", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
json: {
|
||||
token: "AToken",
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route("https://identity.example.org/_matrix/identity/v2/account", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
json: {
|
||||
user_id: user.userId,
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route("https://identity.example.org/_matrix/identity/v2/terms", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
json: {
|
||||
policies: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
const setIdServer = tab.locator(".mx_IdentityServerPicker");
|
||||
const setIdServer = tab.locator(".mx_SetIdServer");
|
||||
await setIdServer.scrollIntoViewIfNeeded();
|
||||
|
||||
const textElement = setIdServer.getByRole("textbox", { name: "Enter a new identity server" });
|
||||
await textElement.click();
|
||||
await textElement.fill("https://identity.example.org");
|
||||
await setIdServer.getByRole("button", { name: "Change" }).click();
|
||||
|
||||
await expect(setIdServer.getByText("Checking server")).toBeVisible();
|
||||
// Accept terms
|
||||
await page.getByTestId("dialog-primary-button").click();
|
||||
// Check identity has changed.
|
||||
await expect(setIdServer.getByText("Your identity server has been changed")).toBeVisible();
|
||||
// Ensure section title is updated.
|
||||
await expect(tab.getByText(`Identity server (identity.example.org)`, { exact: true })).toBeVisible();
|
||||
// Assert that an input area for identity server exists
|
||||
await expect(setIdServer.getByRole("textbox", { name: "Enter a new identity server" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("should enable show integrations as enabled", async ({ app, page, user }) => {
|
||||
test("should enable show integrations as enabled", async ({ app, page }) => {
|
||||
const tab = await app.settings.openUserSettings("Security");
|
||||
|
||||
const setIntegrationManager = tab.locator(".mx_SetIntegrationManager");
|
||||
|
||||
@@ -875,40 +875,6 @@ test.describe("Timeline", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should render a code block", { tag: "@screenshot" }, async ({ page, app, room }) => {
|
||||
await page.goto(`/#/room/${room.roomId}`);
|
||||
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
|
||||
|
||||
// Wait until configuration is finished
|
||||
await expect(
|
||||
page
|
||||
.locator(".mx_GenericEventListSummary_summary")
|
||||
.getByText(`${OLD_NAME} created and configured the room.`),
|
||||
).toBeVisible();
|
||||
|
||||
// Send a code block
|
||||
const composer = app.getComposerField();
|
||||
await composer.fill("```\nconsole.log('Hello, world!');\n```");
|
||||
await composer.press("Enter");
|
||||
|
||||
const tile = page.locator(".mx_EventTile");
|
||||
await expect(tile).toBeVisible();
|
||||
await expect(tile).toMatchScreenshot("code-block.png", { mask: [page.locator(".mx_MessageTimestamp")] });
|
||||
|
||||
// Edit a code block and assert the edited code block has been correctly rendered
|
||||
await tile.hover();
|
||||
await page.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "Edit" }).click();
|
||||
await page
|
||||
.getByRole("textbox", { name: "Edit message" })
|
||||
.fill("```\nconsole.log('Edited: Hello, world!');\n```");
|
||||
await page.getByRole("textbox", { name: "Edit message" }).press("Enter");
|
||||
|
||||
const newTile = page.locator(".mx_EventTile");
|
||||
await expect(newTile).toMatchScreenshot("edited-code-block.png", {
|
||||
mask: [page.locator(".mx_MessageTimestamp")],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("message sending", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ test.describe("UserView", () => {
|
||||
|
||||
const rightPanel = page.locator("#mx_RightPanel");
|
||||
await expect(rightPanel.getByRole("heading", { name: bot.credentials.displayName, exact: true })).toBeVisible();
|
||||
await expect(rightPanel.getByText("1 session")).toBeVisible();
|
||||
await expect(rightPanel).toMatchScreenshot("user-info.png", {
|
||||
mask: [page.locator(".mx_UserInfo_profile_mxid")],
|
||||
css: `
|
||||
|
||||
@@ -202,15 +202,4 @@ export class ElementAppPage {
|
||||
}
|
||||
return this.page.locator(`id=${labelledById ?? describedById}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the notification toast
|
||||
*/
|
||||
public closeNotificationToast(): Promise<void> {
|
||||
// Dismiss "Notification" toast
|
||||
return this.page
|
||||
.locator(".mx_Toast_toast", { hasText: "Notifications" })
|
||||
.getByRole("button", { name: "Dismiss" })
|
||||
.click();
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 985 KiB After Width: | Height: | Size: 983 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |