Compare commits

...

55 Commits

Author SHA1 Message Date
Michael Telatynski
92189c8727 Update message notification sound
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-09-15 17:33:26 +01:00
Florian Duros
0747c9f0e8 chore: add storybook a11y plugin (#30763) 2025-09-15 16:10:15 +00:00
Will Hunt
08487aa945 Fix enabling key backup not working if there is an untrusted key backup (#30707)
* Fix enabling key backup not working if there is an untrusted key backup on the server.

* lint

* Add test for trust situations.

* remove conditional

* Update src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts

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

* Update src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts

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

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2025-09-15 12:18:34 +00:00
Will Hunt
2b5dc7bfd5 Force preload to be false when setting an intent on an Element Call. (#30759)
* force preload

* lnt
2025-09-15 09:54:59 +00:00
Hubert Chathi
9ad239f87f "Verify this device" redesign (#30596)
* add variant of ResetIdentityBody for when the user has no verif. methods

* no longer distinguish between the using having a passphrase or not

* use vertical stack of buttons via EncryptionCard

and update wording

* swap logic order to match rendering order

* use the same dialog when no verification options available

* make it agree with the design more

* allow signing out on initial login

* apply styling changes and remove duplicate elements

* fix and add tests

* add missing snapshot

* Apply suggestions from code review

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

* use a boolean property to disable blurring instead of adding a class

* change string identifiers

* apply changes from review -- simplify logic

* change class name to avoid confusion

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2025-09-12 18:37:14 +00:00
Will Hunt
1e0cdf7b14 Set Element Call "intents" when starting and answering DM calls. (#30730)
* Start to implement intents for DM calls.

* Refactor and fix intent bugs

* Do not default skipLobby in Element Web

* Remove hacks

* cleanup

* Don't template skipLobby or returnToLobby but inject as required

* Revert "Don't template skipLobby or returnToLobby but inject as required"

This reverts commit 35569f35bb.

* lint

* Fix test

* lint

* Use other intents

* Ensure we test all intents

* lint

* cleanup

* Fix room check

* Update imports

* update test

* Fix RoomViewStore test
2025-09-12 13:00:48 +00:00
Hugh Nimmo-Smith
33d3df24f9 Fix handling of 413 server response when uploading media (#30737) 2025-09-12 10:28:40 +00:00
Will Hunt
21a86a3269 Set call owner (#30750) 2025-09-12 09:37:24 +00:00
David Baker
34450d513a Make landmark navigation work with new room list (#30747)
* Make landmark navigation work with new room list

Split out from https://github.com/element-hq/element-web/pull/30640

* Fix landmark selection to work with either room list

* Add test for landmark navigation

* Add test

* Fix test

* Clear mocks between runs
2025-09-12 09:24:56 +00:00
Florian Duros
b6710d19c0 Prevent voice message from displaying spurious errors (#30736)
* fix: avoid to render `AudioPlayerViewModel` when `MAudioBody` is inherited

* fix: avoid `Playback.prepare` to fail when called twice

* fix: add `decoding` to playback type

* refactor: fix circular deps

* refactor: extract `MockedPlayback` from `AudioPlayerViewModel`

* test: add `MAudioBody` basic test

* test: add tests for `MVoiceMessageBody`

* fix: lint
2025-09-12 08:24:51 +00:00
Florian Duros
7fc0cb242c Align default avatar and fix colors in composer pills (#30739)
* fix: align default avatar in composer pills

* fix: use correct color for avatar in composer pills when there is no image

* test(e2e): add test for cider mention

* chore: fix typo
2025-09-11 13:44:08 +00:00
Michael Telatynski
5edcc4c1c4 Use configured URL for link to desktop app in message search settings (#30742)
* Use configured URL for link to desktop app in message search settings

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

* Update snapshots

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-09-11 13:38:58 +00:00
David Baker
e31042f1b1 Fix history visibility when creating space rooms (#30745)
* Fix history visibility when creating space rooms

This line was here which made history visibility different for space
rooms vs normal rooms, making history world readable for public rooms
and shared from the point of invite (rather than joining) for any other
rooms.

I can't see any reason this makes sense, or why space rooms should
have different history visibility defaults to other rooms. It wasn't
commented. Let's just remove the line and make them consistent.

* Fix import

* Add some tests

to asert that we don't randomly change the options that createRoom
passes to the HS.
2025-09-11 13:26:40 +00:00
Will Hunt
1c1f1435be Check HTML-encoded quotes when handling translations for embedded pages (such as welcome.html) (#30743)
* Check HTML encoding on embedded page

* Add tests

* lint
2025-09-11 10:45:35 +00:00
renovate[bot]
a69ce3f64e Update dependency vite to v7.1.5 [SECURITY] (#30732)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-11 08:59:40 +00:00
Valere Fedronic
cd7f1a0638 Fix local room encryption status always not enabled (#30461)
* Fix local room encryption status always not enabled

* refactor: put back the e2e test after merge

* fix: look at e2eStatus in composer of local room

* doc: add docs to `LocalRoom.isEncryptionEnabled`

* test(e2e): check composer doesn't display unencrypted state

* test: update existing tests

* test(e2e): update existing tests

* refactor: move room encryption check in a dedicated function

* refactor: make `isEncryptionEnabled` cleaner

* test: add tests for `LocalRoom.isEncrypted`

* doc: fix `useIsEncrypted` comment

---------

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
2025-09-10 15:09:17 +00:00
RiotRobot
8a1fc65beb Reset matrix-js-sdk back to develop branch 2025-09-10 09:28:53 +00:00
RiotRobot
f7d48bb422 Merge branch 'master' into develop 2025-09-10 09:28:38 +00:00
David Baker
28ca369a10 Remove 'beta' pill from Element Call option (#30726)
* Remove 'beta' pill from Element Call option

This just removes the 'beta' lebelling: it does not take it out of
labs by default just yet.

* Appease import linter
2025-09-10 08:51:28 +00:00
Richard van der Hoff
dad1bd6834 Fix flaky shields playwright test (#30731)
* Playwright: split `logIntoElement` into two

Split up this helper function, so that rather than being a single function with
an optional argument, it is two separate functions.

* Playwright: fix flaky shields test

Wait for the application to redirect to `/#/home` after completing security, so
that we don't end up racing with it.

Fixes https://github.com/element-hq/element-web/issues/28836
2025-09-09 19:30:48 +00:00
David Langley
dba4ca26e8 Add axe compliance for new room list (#30700)
* Add tests for axe violations for the new room list

* axe doesn't like a ul/li with roles listbox/option. Changing to div/button as we have elsewhere like RoomListitemView.

* Fix RoomListPrimaryFilters test

* Justify the items in the primary filter container

to get the dropdown button on the right again

* Update snapshot

* Make the room list itself focusable

As the comment said, there was no real reason it needed to be, except
that there was because of axe. Probably having the children focusable
would be better, but Virtuoso wraps them in more divs which doesn't
satisfy axe's requirements since those inner divs are not the scrollable
ones. I can't see a better option than this right now.

* Update snapshot

---------

Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2025-09-09 16:54:13 +00:00
Will Hunt
a73f4f5803 Stop ringing and remove toast if another device answers a RTC call. (#30728)
* Stop ringing if another device answers a call.

* Add test

* fix check
2025-09-09 15:45:42 +00:00
Valere Fedronic
d594ce479c Allow Element Call to recieve/send call decline events (#30694)
* Allow Element Call to recieve/send call decline events

* fix bad copy paste in test
2025-09-09 14:16:19 +00:00
David Baker
733007cb28 Upgrade compound-web for AXE fix (#30724)
* Upgrade compound-web for AXE fix

I give up waiting for renovate to create the PR

* Update snapshots
2025-09-09 10:31:14 +00:00
ElementRobot
f57660ac14 [create-pull-request] automated change (#30723)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-09-09 06:22:05 +00:00
mxandreas
207173db95 Remove outdated recovery setup options from E2EE docs (#30681)
* Deprecate secure_backup_required and secure_backup_setup_methods in docs.

* Wording enhancements.

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

* Use removal, not deprecation for sake of clarity.

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

* Use removal, not deprecation for sake of clarity.

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

* prettier

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
Co-authored-by: Richard van der Hoff <richard@matrix.org>
2025-09-08 18:16:03 +00:00
Richard van der Hoff
d70a3a695e Patch react-sdk-module-api to suppress confusing logs (#30717)
`Default empty createSecretStorageKey() => null` is unhelpful at best, and
indeed all the other logs from this file are redundant. Let's patch them out to
help log analysis.
2025-09-08 17:01:14 +00:00
David Baker
7ccb9355de Mention our dev room in the contributing guide (#30714)
* Mention our dev room in the contributing guide

It was in there, but only in the tests section, relating to how
to write tests. This adds it in the first section.

* Prettier
2025-09-08 15:23:11 +00:00
Will Hunt
6b510a535b Automatically adjust history visibility when making a room private (#30713)
* Refactor StyledRadioButton to provide proper labels.

* Automatically change history settings to members only if room is made private

* Add tests

* lint

* lint further

* Fix clickable buttons

* Revert functional component-ing

* text tweaks

* update snapshots

* Add unit test for history vis changes

* lint

* Update snapshots

* Fix flakes

* lint
2025-09-08 14:54:15 +00:00
Will Hunt
6d05bfc4c5 Add UIFeature to hide public space and room creation (#30708)
* Add settings to hide public room & space creation.

* Add space changes.

* Add room changes.

* lint

* Add playwright tests

* don't specialcase 1 join rule

* Ensure mocks get cleared

* Fixup test

* Add SpaceCreateMenu component unit-tests

* Fixup create room test asserts

* fix import
2025-09-08 13:53:13 +00:00
ElementRobot
9e7f583acc Localazy Download (#30711)
* [create-pull-request] automated change

* Discard changes to src/i18n/strings/en_EN.json

---------

Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-09-08 09:02:55 +00:00
ElementRobot
ef3b9eb9e4 Localazy Download (#30704)
* [create-pull-request] automated change

* Discard changes to src/i18n/strings/en_EN.json

---------

Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-09-05 08:43:45 +00:00
renovate[bot]
1e5e4a04ad Update dependency @vector-im/compound-web to v8.2.3 (#30701)
* Update dependency @vector-im/compound-web to v8.2.3

* Update snapshot, tabIndex is now overridden as intended.

Original commit + comment -> 8086262e04 (diff-1e0f987f11006689ab011e33003e54e364d2089d66fc6c8f613753a2891fb529R118-R120)

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: David Langley <davidl@element.io>
2025-09-05 08:36:21 +00:00
Richard van der Hoff
5534c0dbe9 Remove remaining support for outdated .well-known settings (#30702)
* Remove remaining support for `secure_backup_setup_methods` option

Support for this .well-known setting had been removed everywhere except in a
rather obscure corner of the code. There are many other problems with this area
(https://github.com/element-hq/element-web/issues/29171) but removing support
for the outdated option is an easy step.

* Remove remaining `secure_backup_required` setting support

Again, this setting was only honoured in the obscure "New Recovery Method"
flow.
2025-09-05 08:32:17 +00:00
Will Hunt
1c30bec083 Ensure container starts if it is mounted with an empty /modules directory. (#30699)
* Set nullglob

* Replace with a if statement (because we're using sh)

* combine if
2025-09-04 16:11:50 +00:00
renovate[bot]
1386bc9f5c Update dependency @vector-im/compound-web to v8.2.2 (#30685)
* Update dependency @vector-im/compound-web to v8.2.2

* Update snapshot

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: David Langley <davidl@element.io>
Co-authored-by: David Langley <langley.dave@gmail.com>
2025-09-04 15:55:36 +00:00
David Langley
48c3d91383 We no longer have the release announcement for the TAC, remove the jest tests. (#30698)
* We no longer have the release announcement for the TAC, remove the jest tests.

* lint

* Trigger checks to fix cla check
2025-09-04 15:16:41 +00:00
Florian Duros
9aa617df1b fix: make url in topic in room intro clickable (#30686)
* fix: make url in topic in room intro clickable

* chore: remove extra line

* refactor: use tag instead variable

* test: add topic tests

* fix: update i18n key
2025-09-04 12:28:53 +00:00
Will Hunt
c17d71a90b Block change recovery key button while a change is ongoing. (#30664)
* Block change recovery key button while a change is ongoing.

* Add disable check

* lint

* Ensure we test that spamming the button doesn't break it.

* Mock out modals

* lint

* add two more clicks

* lint

---------

Co-authored-by: Andy Balaam <andy.balaam@matrix.org>
2025-09-04 09:15:08 +00:00
Will Hunt
07c253d11f Add Playwright tests for settings toggles (#30318)
* Add playwright tests

* import pages/ remove duplicate create-room

* Update screenshots

* Fix accessibility for devtools

* Disable region test

* Fixup headers

* remove extra test

* Fix permissions dialog

* fixup tests

* update snapshot

* Update jest tests

* Clear up playwright tests

* update widget screenshot

* Fix wrong snaps from using wrong compound version

* Revert mistaken s/checkbox/switch/
2025-09-04 07:12:24 +00:00
David Baker
cba341f824 Release announcement for new room list (#30675)
* Release announcement for new room list

* Update snapshots

* Update release announcement tests

* worryingly large snapshot update

* Remove the pinned message release anncounement

* Hopefully fix e2e tests

add missing e2e screenshot and remove one for removed test

* Remove unused i18n strings

* Fix screenshot

* Try straight on the quick settings button

* unused import

* update snapshots

* Fix settings location
2025-09-03 15:25:49 +00:00
Florian Duros
09fe9281a5 Hide advanced settings during room creation when UIFeature.advancedSettings=false (#30684)
* fix: hide advanced settings during room creation when UI.advancedSettings=false

* test: add tests
2025-09-03 15:23:40 +00:00
Tulir Asokan
80375db934 Fix m.topic tests (#30663)
For https://github.com/matrix-org/matrix-js-sdk/pull/4984

Signed-off-by: Tulir Asokan <tulir@maunium.net>
2025-09-03 13:55:54 +00:00
ElementRobot
ea4ccda928 Localazy Download (#30653)
* [create-pull-request] automated change

* Update tests

Hold back one source translation due to inconsistency with related keys

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

* Update screenshot

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-09-03 10:30:36 +00:00
renovate[bot]
69d5acb2f3 Update all non-major dependencies (#30668)
* Update all non-major dependencies

* Make knip happy

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

* Make parseUserAgent happy

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-09-03 09:56:55 +00:00
renovate[bot]
34c2ccebba Update typescript-eslint monorepo to v8.41.0 (#30673)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-03 09:41:35 +00:00
ElementRobot
5deb5097b5 [create-pull-request] automated change (#30679)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-09-03 09:16:00 +00:00
renovate[bot]
eeb14d3b7f Update dependency @testing-library/jest-dom to v6.8.0 (#30670)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-03 09:07:44 +00:00
renovate[bot]
6e88b46f02 Update react monorepo (#30667)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-03 09:07:25 +00:00
renovate[bot]
a50f51257b Update nginxinc/nginx-unprivileged:alpine-slim Docker digest to 0d019e9 (#30665)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 14:04:08 +00:00
renovate[bot]
6f71769466 Update Node.js to f7f28d1 (#30666)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 14:03:25 +00:00
renovate[bot]
08b9f3685d Update dependency @sentry/browser to v10.8.0 (#30669)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 14:02:45 +00:00
renovate[bot]
4a184e3346 Update dependency @types/sdp-transform to v2.15.0 (#30671)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 14:02:28 +00:00
renovate[bot]
de265e1ef6 Update actions/upload-pages-artifact action to v4 (#30674)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 14:01:57 +00:00
Florian Duros
eb086bd795 A11y: improve accessibility of pinned messages (#30558)
* fix: improve aria role and label on pinned message banner

* fix: change pinned message badge background for contrast

* fix: link pinned message button to content

* test: update tests

* fix: add aria-describedby on pinned message badge

* feat: use `aria-describedby` instead of `aria-description`

* test: update room view snapshot

* test: update snapshot

* fix: put id only textual body upper div

* fix: use lodash uniqueId

* test: update snapshots
2025-09-02 13:03:01 +00:00
233 changed files with 5738 additions and 3264 deletions

5
.github/CODEOWNERS vendored
View File

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

View File

@@ -88,7 +88,7 @@ jobs:
run: mdbook build
- name: Upload artifact
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
with:
path: ./book

View File

@@ -13,7 +13,7 @@ import { mergeConfig } from "vite";
const config: StorybookConfig = {
stories: ["../src/shared-components/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
staticDirs: ["../webapp"],
addons: ["@storybook/addon-docs", "@storybook/addon-designs"],
addons: ["@storybook/addon-docs", "@storybook/addon-designs", "@storybook/addon-a11y"],
framework: "@storybook/react-vite",
core: {
disableTelemetry: true,

View File

@@ -100,6 +100,13 @@ const preview: Preview = {
method: "alphabetical",
},
},
a11y: {
/*
* Configure test behavior
* See: https://storybook.js.org/docs/next/writing-tests/accessibility-testing#test-behavior
*/
test: "error",
},
},
};

View File

@@ -2,6 +2,11 @@
Everyone is welcome to contribute code to Element Web, provided that they are willing to license their contributions to Element under a [Contributor License Agreement](https://cla-assistant.io/element-hq/element-web) (CLA). This ensures that their contribution will be made available under an OSI-approved open-source license, currently licensed under Affero General Public License v3 (AGPLv3) or General Public License v3 (GPLv3) at your choice.
If you're contributing, or thinking about contributing, please come & chat to
us in our development room, [#element-dev](https://matrix.to/#/#element-dev:matrix.org).
This is the best place to ask questions about the code, how to work on the project
or whether a change is likely to be accepted.
## How to contribute
The preferred and easiest way to contribute changes to the project is to fork

View File

@@ -1,7 +1,7 @@
# syntax=docker.io/docker/dockerfile:1.17-labs@sha256:9187104f31e3a002a8a6a3209ea1f937fb7486c093cbbde1e14b0fa0d7e4f1b5
# Builder
FROM --platform=$BUILDPLATFORM node:22-bullseye@sha256:9e34ba52e1f3c31ed9bd4d0bcf784f5909db17cda61c220e29c8d7a8ebfb402e AS builder
FROM --platform=$BUILDPLATFORM node:22-bullseye@sha256:f7f28d1962d93cc096ea6327378d990284757fec281ce48e42436e7b4b167fa2 AS builder
# Support custom branch of the js-sdk. This also helps us build images of element-web develop.
ARG USE_CUSTOM_SDKS=false
@@ -19,7 +19,7 @@ RUN /src/scripts/docker-package.sh
RUN cp /src/config.sample.json /src/webapp/config.json
# App
FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:ea6c4b8b568824ea94cd1fabd47e1c4e7c0c04744f344a3793f7e9c8ac3a3636
FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:0d019e980f83728002de7a6d8819d0d4af7179046d3946b8b37749953fbb28e6
# Need root user to install packages & manipulate the usr directory
USER root

View File

@@ -585,6 +585,8 @@ Currently, the following UI feature flags are supported:
- `UIFeature.BulkUnverifiedSessionsReminder` - Display popup reminders to verify or remove unverified sessions. Defaults
to true.
- `UIFeature.locationSharing` - Whether or not location sharing menus will be shown.
- `UIFeature.allowCreatingPublicRooms` - Whether or not public rooms can be created.
- `UIFeature.allowCreatingPublicSpaces` - Whether or not public spaces can be created.
## Undocumented / developer options

View File

@@ -38,45 +38,20 @@ When `force_disable` is true:
Note: If the server is configured to forcibly enable encryption for some or all rooms,
this behaviour will be overridden.
# Secure backup
# Setting up recovery
By default, Element strongly encourages (but does not require) users to set up
Secure Backup so that cross-signing identity key and message keys can be
recovered in case of a disaster where you lose access to all active devices.
recovery so that you can access history on your new devices as well as retain access to your message history and cryptographic identity when you lose all of your devices.
## Requiring secure backup
## Removal of old settings
To require Secure Backup to be configured before Element can be used, set the
following on your homeserver's `/.well-known/matrix/client` config:
Support for the configuration options `secure_backup_required` and `secure_backup_setup_methods`
in the `/.well-known/matrix/client` config has been removed.
```json
{
"io.element.e2ee": {
"secure_backup_required": true
}
}
```
## Preferring setup methods
By default, Element offers users a choice of a random key or user-chosen
passphrase when setting up Secure Backup. If a homeserver admin would like to
only offer one of these, you can signal this via the
`/.well-known/matrix/client` config, for example:
```json
{
"io.element.e2ee": {
"secure_backup_setup_methods": ["passphrase"]
}
}
```
The field `secure_backup_setup_methods` is an array listing the methods the
client should display. Supported values currently include `key` and
`passphrase`. If the `secure_backup_setup_methods` field is not present or
exists but does not contain any supported methods, Element will fallback to the
default value of: `["key", "passphrase"]`.
Setting up recovery is now always recommended to all users by showing a one-off toast and a
permanent red dot on the _Encryption_ tab in the _Settings_ dialog. When creating a new
recovery key, the UI only supports auto-generated keys. Using an existing (custom) passphrase
still works, but is not exposed in the UI when setting up recovery.
# Compatibility

View File

@@ -2,7 +2,6 @@ import { KnipConfig } from "knip";
export default {
entry: [
"src/vector/index.ts",
"src/serviceworker/index.ts",
"src/workers/*.worker.ts",
"src/utils/exportUtils/exportJS.js",
@@ -12,8 +11,6 @@ export default {
"res/decoder-ring/**",
"res/jitsi_external_api.min.js",
"docs/**",
// Used by jest
"__mocks__/maplibre-gl.js",
],
project: ["**/*.{js,ts,jsx,tsx}"],
ignore: [
@@ -53,6 +50,7 @@ export default {
ignoreBinaries: [
// Used in scripts & workflows
"jq",
"wait-on",
],
ignoreExportsUsedInFile: true,
} satisfies KnipConfig;

View File

@@ -75,8 +75,8 @@
"resolutions": {
"**/pretty-format/react-is": "19.1.1",
"@playwright/test": "1.54.2",
"@types/react": "19.1.10",
"@types/react-dom": "19.1.7",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"oidc-client-ts": "3.3.0",
"jwt-decode": "4.0.0",
"caniuse-lite": "1.0.30001724",
@@ -134,7 +134,7 @@
"maplibre-gl": "^5.0.0",
"matrix-encrypt-attachment": "^1.0.3",
"matrix-events-sdk": "0.0.1",
"matrix-js-sdk": "38.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",
@@ -142,7 +142,7 @@
"opus-recorder": "^8.0.3",
"pako": "^2.0.3",
"png-chunks-extract": "^1.0.0",
"posthog-js": "1.260.1",
"posthog-js": "1.261.0",
"qrcode": "1.5.4",
"re-resizable": "6.11.2",
"react": "^19.0.0",
@@ -158,7 +158,7 @@
"sanitize-html": "2.17.0",
"tar-js": "^0.3.0",
"temporal-polyfill": "^0.3.0",
"ua-parser-js": "^1.0.2",
"ua-parser-js": "1.0.40",
"uuid": "^11.0.0",
"what-input": "^5.2.10"
},
@@ -184,13 +184,14 @@
"@babel/preset-typescript": "^7.12.7",
"@babel/runtime": "^7.12.5",
"@casualbot/jest-sonar-reporter": "2.2.7",
"@element-hq/element-call-embedded": "0.14.1",
"@element-hq/element-call-embedded": "0.15.0",
"@element-hq/element-web-playwright-common": "^1.4.6",
"@peculiar/webcrypto": "^1.4.3",
"@playwright/test": "^1.50.1",
"@principalstudio/html-webpack-inject-preload": "^1.2.7",
"@rrweb/types": "^2.0.0-alpha.18",
"@sentry/webpack-plugin": "^4.0.0",
"@storybook/addon-a11y": "^9.0.18",
"@storybook/addon-designs": "^10.0.1",
"@storybook/addon-docs": "^9.0.12",
"@storybook/icons": "^1.4.0",
@@ -222,9 +223,9 @@
"@types/node-fetch": "^2.6.2",
"@types/pako": "^2.0.0",
"@types/qrcode": "^1.3.5",
"@types/react": "19.1.10",
"@types/react": "19.1.12",
"@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "19.1.7",
"@types/react-dom": "19.1.9",
"@types/react-transition-group": "^4.4.0",
"@types/sanitize-html": "2.16.0",
"@types/sdp-transform": "^2.4.10",

View File

@@ -11,3 +11,42 @@ index 917a7fc..a2710c6 100644
didOkOrSubmit: boolean;
model: M;
}>;
diff --git a/node_modules/@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions.js b/node_modules/@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions.js
index 5d422ed..b823add 100644
--- a/node_modules/@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions.js
+++ b/node_modules/@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions.js
@@ -124,34 +124,28 @@ var DefaultCryptoSetupExtensions = /*#__PURE__*/function (_CryptoSetupExtension)
(0, _createClass2["default"])(DefaultCryptoSetupExtensions, [{
key: "examineLoginResponse",
value: function examineLoginResponse(response, credentials) {
- console.log("Default empty examineLoginResponse() => void");
}
}, {
key: "persistCredentials",
value: function persistCredentials(credentials) {
- console.log("Default empty persistCredentials() => void");
}
}, {
key: "getSecretStorageKey",
value: function getSecretStorageKey() {
- console.log("Default empty getSecretStorageKey() => null");
return null;
}
}, {
key: "createSecretStorageKey",
value: function createSecretStorageKey() {
- console.log("Default empty createSecretStorageKey() => null");
return null;
}
}, {
key: "catchAccessSecretStorageError",
value: function catchAccessSecretStorageError(e) {
- console.log("Default catchAccessSecretStorageError() => void");
}
}, {
key: "setupEncryptionNeeded",
value: function setupEncryptionNeeded(args) {
- console.log("Default setupEncryptionNeeded() => false");
return false;
}
}, {

View File

@@ -14,6 +14,9 @@ const CtrlOrMeta = process.platform === "darwin" ? "Meta" : "Control";
test.describe("Composer", () => {
test.use({
displayName: "Janet",
botCreateOpts: {
displayName: "Bob",
},
});
test.use({
@@ -94,5 +97,22 @@ test.describe("Composer", () => {
).toBeVisible();
});
});
test("can send mention", { tag: "@screenshot" }, async ({ page, bot, app }) => {
// Set up a private room so we have another user to mention
await app.client.createRoom({
is_direct: true,
invite: [bot.credentials.userId],
});
await app.viewRoomByName("Bob");
const composer = page.getByRole("textbox", { name: "Send an unencrypted message…" });
await composer.pressSequentially("@bob");
await page.getByRole("option", { name: "Bob" }).click();
await expect(composer.getByText("Bob")).toBeVisible();
await expect(composer).toMatchScreenshot("mention.png");
await composer.press("Enter");
await expect(page.locator(".mx_EventTile_body", { hasText: "Bob" })).toBeVisible();
});
});
});

View File

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

View File

@@ -154,8 +154,8 @@ test.describe("Cryptography", function () {
await app.client.bootstrapCrossSigning(aliceCredentials);
await startDMWithBob(page, bob);
// send first message
await page.getByRole("textbox", { name: "Send an unencrypted message…" }).fill("Hey!");
await page.getByRole("textbox", { name: "Send an unencrypted message…" }).press("Enter");
await page.getByRole("textbox", { name: "Send a message…" }).fill("Hey!");
await page.getByRole("textbox", { name: "Send a message…" }).press("Enter");
await checkDMRoom(page);
const bobRoomId = await bobJoin(page, bob);
// We no longer show the grey badge in the composer, check that it is not there.

View File

@@ -38,7 +38,7 @@ test.describe("Dehydration", () => {
// Reset the identity key
const settings = await app.settings.openUserSettings("Encryption");
await settings.getByRole("button", { name: "Verify this device" }).click();
await page.getByRole("button", { name: "Proceed with reset" }).click();
await page.getByRole("button", { name: "Can't confirm?" }).click();
await page.getByRole("button", { name: "Continue" }).click();
// Set up recovery
@@ -106,7 +106,7 @@ test.describe("Dehydration", () => {
await logIntoElement(page, credentials);
// Oh no, we forgot our recovery key - reset our identity
await page.locator(".mx_AuthPage").getByRole("button", { name: "Reset all" }).click();
await page.locator(".mx_AuthPage").getByRole("button", { name: "Can't confirm" }).click();
await expect(
page.getByRole("heading", { name: "Are you sure you want to reset your identity?" }),
).toBeVisible();

View File

@@ -36,13 +36,13 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
expectedBackupVersion = res.expectedBackupVersion;
});
// Click the "Verify with another device" button, and have the bot client auto-accept it.
// Click the "Use another device" button, and have the bot client auto-accept it.
async function initiateAliceVerificationRequest(page: Page): Promise<JSHandle<VerificationRequest>> {
// alice bot waits for verification request
const promiseVerificationRequest = waitForVerificationRequest(aliceBotClient);
// Click on "Verify with another device"
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with another device" }).click();
// Click on "Use another device"
await page.locator(".mx_AuthPage").getByRole("button", { name: "Use another device" }).click();
// alice bot responds yes to verification request from alice
return promiseVerificationRequest;
@@ -203,7 +203,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
/** Helper for the three tests above which verify by recovery key */
async function enterRecoveryKeyAndCheckVerified(page: Page, app: ElementAppPage, recoveryKey: string) {
await page.getByRole("button", { name: "Verify with Recovery Key or Phrase" }).click();
await page.getByRole("button", { name: "Use recovery key" }).click();
// Enter the recovery key
const dialog = page.locator(".mx_Dialog");

View File

@@ -14,7 +14,7 @@ import {
createSecondBotDevice,
createSharedRoomWithUser,
enableKeyBackup,
logIntoElement,
logIntoElementAndVerify,
logOutOfElement,
verify,
waitForDevices,
@@ -195,7 +195,7 @@ test.describe("Cryptography", function () {
window.localStorage.clear();
});
await page.reload();
await logIntoElement(page, aliceCredentials, securityKey);
await logIntoElementAndVerify(page, aliceCredentials, securityKey);
/* go back to the test room and find Bob's message again */
await app.viewRoomById(testRoomId);

View File

@@ -8,7 +8,7 @@
import { type GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
import { test, expect } from "../../element-web-test";
import { createBot, deleteCachedSecrets, disableKeyBackup, logIntoElement } from "./utils";
import { createBot, deleteCachedSecrets, disableKeyBackup, logIntoElementAndVerify } from "./utils";
import { type Bot } from "../../pages/bot";
test.describe("Key storage out of sync toast", () => {
@@ -18,7 +18,7 @@ test.describe("Key storage out of sync toast", () => {
const res = await createBot(page, homeserver, credentials);
recoveryKey = res.recoveryKey;
await logIntoElement(page, credentials, recoveryKey.encodedPrivateKey);
await logIntoElementAndVerify(page, credentials, recoveryKey.encodedPrivateKey);
await deleteCachedSecrets(page);
@@ -65,7 +65,7 @@ test.describe("'Turn on key storage' toast", () => {
const recoveryKey = res.recoveryKey;
botClient = res.botClient;
await logIntoElement(page, credentials, recoveryKey.encodedPrivateKey);
await logIntoElementAndVerify(page, credentials, recoveryKey.encodedPrivateKey);
// We won't be prompted for crypto setup unless we have an e2e room, so make one
await page.getByRole("button", { name: "Add room" }).click();

View File

@@ -206,32 +206,42 @@ export async function checkDeviceIsConnectedKeyBackup(
/**
* Fill in the login form in element with the given creds.
*
* If a `securityKey` is given, verifies the new device using the key.
*/
export async function logIntoElement(page: Page, credentials: Credentials, securityKey?: string) {
export async function logIntoElement(page: Page, credentials: Credentials) {
await page.goto("/#/login");
await page.getByRole("textbox", { name: "Username" }).fill(credentials.userId);
await page.getByPlaceholder("Password").fill(credentials.password);
await page.getByRole("button", { name: "Sign in" }).click();
}
// 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();
/**
* Fill in the login form in Element with the given creds, and then complete the `CompleteSecurity` step, using the
* given recovery key. (Normally this will verify the new device using the secrets from 4S.)
*
* Afterwards, waits for the application to redirect to the home page.
*/
export async function logIntoElementAndVerify(page: Page, credentials: Credentials, recoveryKey: string) {
await logIntoElement(page, credentials);
const useSecurityKey = page.locator(".mx_Dialog").getByRole("button", { name: "use your Recovery Key" });
// If the user has set a recovery *passphrase*, they'll be prompted for that first and have to click
// through to enter the recovery key which is what we have here. If they haven't, they'll be prompted
// for a recovery key straight away. We click the button if it's there so this works in both cases.
if (await useSecurityKey.isVisible()) {
await useSecurityKey.click();
}
// Fill in the recovery key
await page.locator(".mx_Dialog").getByTitle("Recovery key").fill(securityKey);
await page.getByRole("button", { name: "Continue", disabled: false }).click();
await page.getByRole("button", { name: "Done" }).click();
await page.locator(".mx_AuthPage").getByRole("button", { name: "Use recovery key" }).click();
const useSecurityKey = page.locator(".mx_Dialog").getByRole("button", { name: "Use recovery key" });
// If the user has set a recovery *passphrase*, they'll be prompted for that first and have to click
// through to enter the recovery key which is what we have here. If they haven't, they'll be prompted
// for a recovery key straight away. We click the button if it's there so this works in both cases.
if (await useSecurityKey.isVisible()) {
await useSecurityKey.click();
}
// Fill in the recovery key
await page.locator(".mx_Dialog").getByTitle("Recovery key").fill(recoveryKey);
await page.getByRole("button", { name: "Continue", disabled: false }).click();
await page.getByRole("button", { name: "Done" }).click();
// The application should now redirect to `/#/home`. Wait for that to happen, otherwise if a test immediately does
// a `viewRoomById` or similar, it could race.
await page.waitForURL("/#/home");
}
/**
@@ -262,7 +272,7 @@ export async function logOutOfElement(page: Page, discardKeys: boolean = false)
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: "Use recovery key" }).click();
await app.page.locator(".mx_Dialog").getByTitle("Recovery key").fill(securityKey);
await app.page.getByRole("button", { name: "Continue", disabled: false }).click();
await app.page.getByRole("button", { name: "Done" }).click();

View File

@@ -0,0 +1,33 @@
/*
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("Devtools", () => {
test.use({
displayName: "Alice",
});
test("should render the devtools", { tag: "@screenshot" }, async ({ page, homeserver, user, app, axe }) => {
await app.client.createRoom({ name: "Test Room" });
await app.viewRoomByName("Test Room");
const composer = app.getComposer().locator("[contenteditable]");
await composer.fill("/devtools");
await composer.press("Enter");
const dialog = page.locator(".mx_Dialog");
await dialog.getByLabel("Developer mode").check();
axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view.
await expect(axe).toHaveNoViolations();
await expect(dialog).toMatchScreenshot("devtools-dialog.png", {
css: `.mx_CopyableText {
display: none;
}`,
});
});
});

View File

@@ -0,0 +1,38 @@
/*
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 { SettingLevel } from "../../../src/settings/SettingLevel";
import { test, expect } from "../../element-web-test";
test.describe("Room upgrade dialog", () => {
test.use({
displayName: "Alice",
});
test(
"should render the room upgrade dialog",
{ tag: "@screenshot" },
async ({ page, homeserver, user, app, axe }) => {
// Enable developer mode
await app.settings.setValue("developerMode", null, SettingLevel.ACCOUNT, true);
await app.client.createRoom({ name: "Test Room" });
await app.viewRoomByName("Test Room");
const composer = app.getComposer().locator("[contenteditable]");
// Pick a room version that is likely to be supported by all our target homeservers.
await composer.fill("/upgraderoom 5");
await composer.press("Enter");
const dialog = page.locator(".mx_Dialog");
await dialog.getByLabel("Automatically invite members from this room to the new one").check();
axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view.
await expect(axe).toHaveNoViolations();
await expect(dialog).toMatchScreenshot("upgrade-room.png");
},
);
});

View File

@@ -0,0 +1,28 @@
/*
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("Decline and block invite dialog", function () {
test.use({
displayName: "Hanako",
});
test(
"should show decline and block dialog for a room",
{ tag: "@screenshot" },
async ({ page, app, user, bot, axe }) => {
await bot.createRoom({ name: "Test Room", invite: [user.userId] });
await app.viewRoomByName("Test Room");
await page.getByRole("button", { name: "Decline and block" }).click();
axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view.
await expect(axe).toHaveNoViolations();
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("decline-and-block-invite-empty.png");
},
);
});

View File

@@ -41,7 +41,7 @@ test.describe("Room list", () => {
}
});
test("should render the room list", { tag: "@screenshot" }, async ({ page, app, user }) => {
test("should render the room list", { tag: "@screenshot" }, async ({ page, app, user, axe }) => {
const roomListView = getRoomList(page);
await expect(roomListView.getByRole("option", { name: "Open room room29" })).toBeVisible();
await expect(roomListView).toMatchScreenshot("room-list.png");
@@ -54,6 +54,7 @@ test.describe("Room list", () => {
// scrollListToBottom seems to leave the mouse hovered over the list, move it away.
await page.getByRole("button", { name: "User menu" }).hover();
await expect(axe).toHaveNoViolations();
await expect(roomListView).toMatchScreenshot("room-list-scrolled.png");
});

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024, 2025 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
@@ -57,4 +57,26 @@ test.describe("Location sharing", { tag: "@no-firefox" }, () => {
await expect(page.locator(".mx_Marker")).toBeVisible();
});
test(
"is prompted for and can consent to live location sharing",
{ tag: "@screenshot" },
async ({ page, user, app, axe }) => {
await app.viewRoomById(await app.client.createRoom({}));
const composerOptions = await app.openMessageComposerOptions();
await composerOptions.getByRole("menuitem", { name: "Location", exact: true }).click();
const menu = page.locator(".mx_LocationShareMenu");
await menu.getByRole("button", { name: "My live location" }).click();
await menu.getByLabel("Enable live location sharing").check();
axe.disableRules([
"color-contrast", // XXX: Inheriting colour contrast issues from room view.
"region", // XXX: ContextMenu managed=false does not provide a role.
]);
await expect(axe).toHaveNoViolations();
await expect(menu).toMatchScreenshot("location-live-share-dialog.png");
},
);
});

View File

@@ -186,7 +186,7 @@ test.describe("Login", () => {
await page.goto("/");
await login(page, homeserver, credentials);
await expect(page.getByRole("heading", { name: "Verify this device", level: 1 })).toBeVisible();
await expect(page.getByRole("heading", { name: "Confirm your identity", level: 2 })).toBeVisible();
await expect(page.getByRole("button", { name: "Skip verification for now" })).toBeVisible();
});
@@ -219,7 +219,7 @@ test.describe("Login", () => {
await page.goto("/");
await login(page, homeserver, credentials);
await expect(page.getByRole("heading", { name: "Verify this device", level: 1 })).toBeVisible();
await expect(page.getByRole("heading", { name: "Confirm your identity", level: 2 })).toBeVisible();
await expect(page.getByRole("button", { name: "Skip verification for now" })).toBeVisible();
});
@@ -254,10 +254,10 @@ test.describe("Login", () => {
await page.goto("/");
await login(page, homeserver, credentials);
const h1 = page.getByRole("heading", { name: "Verify this device", level: 1 });
await expect(h1).toBeVisible();
const h2 = page.getByRole("heading", { name: "Confirm your identity", level: 2 });
await expect(h2).toBeVisible();
await expect(h1.locator(".mx_CompleteSecurity_skip")).toHaveCount(0);
await expect(h2.locator(".mx_CompleteSecurity_skip")).toHaveCount(0);
});
test("Continues to show verification prompt after cancelling device verification", async ({
@@ -274,18 +274,18 @@ test.describe("Login", () => {
// Load the page and see that we are asked to verify
await page.goto("/#/welcome");
await login(page, homeserver, credentials);
let h1 = page.getByRole("heading", { name: "Verify this device", level: 1 });
await expect(h1).toBeVisible();
let h2 = page.getByRole("heading", { name: "Confirm your identity", level: 2 });
await expect(h2).toBeVisible();
// Click "Verify with another device"
await page.getByRole("button", { name: "Verify with another device" }).click();
// Click "Use another device"
await page.getByRole("button", { name: "Use another device" }).click();
// Cancel the new dialog
await page.getByRole("button", { name: "Close dialog" }).click();
// Check that we are still being asked to verify
h1 = page.getByRole("heading", { name: "Verify this device", level: 1 });
await expect(h1).toBeVisible();
h2 = page.getByRole("heading", { name: "Confirm your identity", level: 2 });
await expect(h2).toBeVisible();
});
});
@@ -303,18 +303,18 @@ test.describe("Login", () => {
await page.goto("/");
await login(page, homeserver, credentials);
await expect(page.getByRole("heading", { name: "Verify this device", level: 1 })).toBeVisible();
await expect(page.getByRole("heading", { name: "Confirm your identity", level: 2 })).toBeVisible();
// Start the reset process
await page.getByRole("button", { name: "Proceed with reset" }).click();
await page.getByRole("button", { name: "Can't confirm?" }).click();
// First try cancelling and restarting
await page.getByRole("button", { name: "Cancel" }).click();
await page.getByRole("button", { name: "Proceed with reset" }).click();
await page.getByRole("button", { name: "Can't confirm?" }).click();
// Then click outside the dialog and restart
await page.getByRole("link", { name: "Powered by Matrix" }).click({ force: true });
await page.getByRole("button", { name: "Proceed with reset" }).click();
await page.getByRole("button", { name: "Can't confirm?" }).click();
// Finally we actually continue
await page.getByRole("button", { name: "Continue" }).click();

View File

@@ -129,8 +129,8 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Continue" }).click();
// We should be in (we see an error because we have no recovery key).
await expect(page.getByText("Unable to verify this device")).toBeVisible();
// We should be in
await expect(page.getByText("Confirm your identity")).toBeVisible();
});
test.describe("with force_verification on", () => {
@@ -162,7 +162,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
await page.getByRole("button", { name: "Continue" }).click();
// We should be being warned that we need to verify (but we can't)
await expect(page.getByText("Unable to verify this device")).toBeVisible();
await expect(page.getByText("Confirm your identity")).toBeVisible();
// And there should be no way to close this prompt
await expect(page.getByRole("button", { name: "Skip verification for now" })).not.toBeVisible();
@@ -210,7 +210,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
await expect(page.getByRole("button", { name: "Skip verification for now" })).not.toBeVisible();
// When we start verifying with another device
await page.getByRole("button", { name: "Verify with another device" }).click();
await page.getByRole("button", { name: "Use another device" }).click();
// And then cancel it
await page.getByRole("button", { name: "Close dialog" }).click();
@@ -227,7 +227,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
* Perform interactive emoji verification for a new device.
*/
async function verifyUsingOtherDevice(deviceToVerifyPage: Page, alreadyVerifiedDevicePage: Page) {
await deviceToVerifyPage.getByRole("button", { name: "Verify with another device" }).click();
await deviceToVerifyPage.getByRole("button", { name: "Use another device" }).click();
await alreadyVerifiedDevicePage.getByRole("button", { name: "Verify session" }).click();
await alreadyVerifiedDevicePage.getByRole("button", { name: "Start" }).click();
await alreadyVerifiedDevicePage.getByRole("button", { name: "They match" }).click();

View File

@@ -30,9 +30,8 @@ export class Helpers {
/**
* Get the release announcement with the given name.
* @param name
* @private
*/
private getReleaseAnnouncement(name: string) {
public getReleaseAnnouncement(name: string) {
return this.page.getByRole("dialog", { name });
}
@@ -55,16 +54,6 @@ export class Helpers {
assertReleaseAnnouncementIsNotVisible(name: string) {
return expect(this.getReleaseAnnouncement(name)).not.toBeVisible();
}
/**
* Mark the release announcement with the given name as read.
* If the release announcement is not visible, this will throw an error.
* @param name
*/
async markReleaseAnnouncementAsRead(name: string) {
const dialog = this.getReleaseAnnouncement(name);
await dialog.getByRole("button", { name: "Ok" }).click();
}
}
export { expect };

View File

@@ -22,25 +22,25 @@ test.describe("Release announcement", () => {
await app.viewRoomById(roomId);
await use({ roomId });
},
labsFlags: ["feature_new_room_list"],
});
test(
"should display the pinned messages release announcement",
"should display the new room list release announcement",
{ tag: "@screenshot" },
async ({ page, app, room, util }) => {
await app.toggleRoomInfoPanel();
const name = "All new pinned messages";
const name = "Chats has a new look!";
// The release announcement should be displayed
await util.assertReleaseAnnouncementIsVisible(name);
// Hide the release announcement
await util.markReleaseAnnouncementAsRead(name);
const dialog = util.getReleaseAnnouncement(name);
await dialog.getByRole("button", { name: "Next" }).click();
await util.assertReleaseAnnouncementIsNotVisible(name);
await page.reload();
await app.toggleRoomInfoPanel();
await expect(page.getByRole("menuitem", { name: "Pinned messages" })).toBeVisible();
await expect(page.getByRole("button", { name: "Room options" })).toBeVisible();
// Check that once the release announcement has been marked as viewed, it does not appear again
await util.assertReleaseAnnouncementIsNotVisible(name);
},

View File

@@ -0,0 +1,113 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { UIFeature } from "../../../src/settings/UIFeature";
import { test, expect } from "../../element-web-test";
const name = "Test room";
const topic = "A decently explanatory topic for a test room.";
test.describe("Create Room", () => {
test.use({ displayName: "Jim" });
test(
"should create a public room with name, topic & address set",
{ tag: "@screenshot" },
async ({ page, user, app, axe }) => {
const dialog = await app.openCreateRoomDialog();
// Fill name & topic
await dialog.getByRole("textbox", { name: "Name" }).fill(name);
await dialog.getByRole("textbox", { name: "Topic" }).fill(topic);
// Change room to public
await dialog.getByRole("button", { name: "Room visibility" }).click();
await dialog.getByRole("option", { name: "Public room" }).click();
// Fill room address
await dialog.getByRole("textbox", { name: "Room address" }).fill("test-create-room-standard");
axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view.
await expect(axe).toHaveNoViolations();
// Snapshot it
await expect(dialog).toMatchScreenshot("create-room.png");
// Submit
await dialog.getByRole("button", { name: "Create room" }).click();
await expect(page).toHaveURL(new RegExp(`/#/room/#test-create-room-standard:${user.homeServer}`));
const header = page.locator(".mx_RoomHeader");
await expect(header).toContainText(name);
},
);
test("should allow us to start a chat and show encryption state", async ({ page, user, app }) => {
await page.getByRole("button", { name: "Add", exact: true }).click();
await page.getByText("Start new chat").click();
await page.getByTestId("invite-dialog-input").fill(user.userId);
await page.getByRole("button", { name: "Go" }).click();
await expect(page.getByText("Encryption enabled")).toBeVisible();
await expect(page.getByText("Send your first message to")).toBeVisible();
const composer = page.getByRole("region", { name: "Message composer" });
await expect(composer.getByRole("textbox", { name: "Send a message…" })).toBeVisible();
});
test("should create a video room", { tag: "@screenshot" }, async ({ page, user, app }) => {
await app.settings.setValue("feature_video_rooms", null, SettingLevel.DEVICE, true);
const dialog = await app.openCreateRoomDialog("New video room");
// Fill name & topic
await dialog.getByRole("textbox", { name: "Name" }).fill(name);
await dialog.getByRole("textbox", { name: "Topic" }).fill(topic);
// Change room to public
await dialog.getByRole("button", { name: "Room visibility" }).click();
await dialog.getByRole("option", { name: "Public room" }).click();
// Fill room address
await dialog.getByRole("textbox", { name: "Room address" }).fill("test-create-room-video");
// Snapshot it
await expect(dialog).toMatchScreenshot("create-video-room.png");
// Submit
await dialog.getByRole("button", { name: "Create video room" }).click();
await expect(page).toHaveURL(new RegExp(`/#/room/#test-create-room-video:${user.homeServer}`));
const header = page.locator(".mx_RoomHeader");
await expect(header).toContainText(name);
});
test.describe("Should hide public room option if not allowed", () => {
test.use({
config: {
setting_defaults: {
[UIFeature.AllowCreatingPublicRooms]: false,
},
},
});
test("should disallow creating public rooms", { tag: "@screenshot" }, async ({ page, user, app, axe }) => {
const dialog = await app.openCreateRoomDialog();
// Fill name & topic
await dialog.getByRole("textbox", { name: "Name" }).fill(name);
await dialog.getByRole("textbox", { name: "Topic" }).fill(topic);
axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view.
await expect(axe).toHaveNoViolations();
// Snapshot it
await expect(dialog).toMatchScreenshot("create-room-no-public.png");
// Submit
await dialog.getByRole("button", { name: "Create room" }).click();
await expect(page).toHaveURL(new RegExp(`/#/room/!.+`));
const header = page.locator(".mx_RoomHeader");
await expect(header).toContainText(name);
});
});
});

View File

@@ -160,15 +160,15 @@ test.describe("Encryption tab", () => {
// We will reset our identity
await settings.getByRole("button", { name: "Verify this device" }).click();
await page.getByRole("button", { name: "Proceed with reset" }).click();
await page.getByRole("button", { name: "Can't confirm?" }).click();
// First try cancelling and restarting
await page.getByRole("button", { name: "Cancel" }).click();
await page.getByRole("button", { name: "Proceed with reset" }).click();
await page.getByRole("button", { name: "Can't confirm?" }).click();
// Then click outside the dialog and restart
await page.locator("li").filter({ hasText: "Encryption" }).click({ force: true });
await page.getByRole("button", { name: "Proceed with reset" }).click();
await page.getByRole("button", { name: "Can't confirm?" }).click();
// Finally we actually continue
await page.getByRole("button", { name: "Continue" }).click();

View File

@@ -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: "Use recovery key" }).click();
await this.enterRecoveryKey(recoveryKey);
await this.page.getByRole("button", { name: "Done" }).click();
}
@@ -104,7 +104,10 @@ class Helpers {
const clipboardContent = await this.app.getClipboard();
await dialog.getByRole("textbox").fill(clipboardContent);
await dialog.getByRole("button", { name: confirmButtonLabel }).click();
const button = dialog.getByRole("button", { name: confirmButtonLabel });
await button.click();
// Button should disable immediately after clicking.
await expect(button).toBeDisabled();
await expect(this.getEncryptionRecoverySection()).toMatchScreenshot("default-recovery.png");
}
}

View File

@@ -0,0 +1,28 @@
/*
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 { SettingLevel } from "../../../../src/settings/SettingLevel";
test.describe("Notifications 2 tab", () => {
test.use({
displayName: "Alice",
});
test("should display notification settings", { tag: "@screenshot" }, async ({ page, app, user, axe }) => {
await app.settings.setValue("feature_notification_settings2", null, SettingLevel.DEVICE, true);
await page.setViewportSize({ width: 1024, height: 2000 });
const settings = await app.settings.openUserSettings("Notifications");
axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view.
await expect(axe).toHaveNoViolations();
await expect(settings).toMatchScreenshot("standard-notifications-2-settings.png", {
// Mask the mxid.
mask: [settings.locator("#mx_NotificationSettings2_MentionCheckbox span")],
});
});
});

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 { test, expect } from "../../../element-web-test";
test.describe("Notifications tab", () => {
test.use({
displayName: "Alice",
});
test("should display notification settings", { tag: "@screenshot" }, async ({ page, app, user, axe }) => {
await page.setViewportSize({ width: 1024, height: 1400 });
const settings = await app.settings.openUserSettings("Notifications");
await settings.getByLabel("Enable notifications for this account").check();
await settings.getByLabel("Enable notifications for this device").check();
axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view.
await expect(axe).toHaveNoViolations();
await expect(settings).toMatchScreenshot("standard-notification-settings.png");
});
});

View File

@@ -8,7 +8,7 @@
import { type Locator } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { test, expect } from "../../../element-web-test";
test.describe("Roles & Permissions room settings tab", () => {
const roomName = "Test room";

View File

@@ -0,0 +1,121 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type Locator } from "@playwright/test";
import { test, expect } from "../../../element-web-test";
test.describe("Roles & Permissions room settings tab", () => {
const roomName = "Test room";
test.use({
displayName: "Alice",
});
let settings: Locator;
test.beforeEach(async ({ user, app }) => {
await app.client.createRoom({
name: roomName,
power_level_content_override: {
events: {
// Set the join rules as lower than the history vis to test an edge case.
["m.room.join_rules"]: 80,
["m.room.history_visibility"]: 100,
},
},
});
await app.viewRoomByName(roomName);
settings = await app.settings.openRoomSettings("Security & Privacy");
});
test(
"should be able to toggle on encryption in a room",
{ tag: "@screenshot" },
async ({ page, app, user, axe }) => {
await page.setViewportSize({ width: 1024, height: 1400 });
const encryptedToggle = settings.getByLabel("Encrypted");
await encryptedToggle.click();
// Accept the dialog.
await page.getByRole("button", { name: "Ok " }).click();
await expect(encryptedToggle).toBeChecked();
await expect(encryptedToggle).toBeDisabled();
await settings.getByLabel("Only send messages to verified users.").check();
axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view.
await expect(axe).toHaveNoViolations();
await expect(settings).toMatchScreenshot("room-security-settings.png");
},
);
test(
"should automatically adjust history visibility when a room is changed from public to private",
{ tag: "@screenshot" },
async ({ page, app, user, axe }) => {
await page.setViewportSize({ width: 1024, height: 1400 });
const settingsGroupAccess = page.getByRole("group", { name: "Access" });
const settingsGroupHistory = page.getByRole("group", { name: "Who can read history?" });
await settingsGroupAccess.getByText("Public").click();
await settingsGroupHistory.getByText("Anyone").click();
// Test that we have the warning appear.
axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view.
await expect(axe).toHaveNoViolations();
await expect(settings).toMatchScreenshot("room-security-settings-world-readable.png");
await settingsGroupAccess.getByText("Private (invite only)").click();
// Element should have automatically set the room to "sharing" history visibility
await expect(
settingsGroupHistory.getByText("Members only (since the point in time of selecting this option)"),
).toBeChecked();
},
);
test(
"should disallow changing from public to private if the user cannot alter history",
{ tag: "@screenshot" },
async ({ page, app, user, bot }) => {
await page.setViewportSize({ width: 1024, height: 1400 });
const settingsGroupAccess = page.getByRole("group", { name: "Access" });
const settingsGroupHistory = page.getByRole("group", { name: "Who can read history?" });
await settingsGroupAccess.getByText("Public").click();
await settingsGroupHistory.getByText("Anyone").click();
// De-op ourselves
await app.settings.switchTab("Roles & Permissions");
// Wait for the permissions list to be visible
await expect(settings.getByRole("heading", { name: "Permissions" })).toBeVisible();
const ourComboBox = settings.getByRole("combobox", { name: user.userId });
await ourComboBox.selectOption("Custom level");
const ourPl = settings.getByRole("spinbutton", { name: user.userId });
await ourPl.fill("80");
await ourPl.blur(); // Shows a warning on
// Accept the de-op
await page.getByRole("button", { name: "Continue" }).click();
await settings.getByRole("button", { name: "Apply", disabled: false }).click();
await app.settings.switchTab("Security & Privacy");
await settingsGroupAccess.getByText("Private (invite only)").click();
// Element should have automatically set the room to "sharing" history visibility
const errorDialog = page.getByRole("heading", { name: "Cannot make room private" });
await expect(errorDialog).toBeVisible();
await errorDialog.getByLabel("OK");
await expect(settingsGroupHistory.getByText("Anyone")).toBeChecked();
},
);
});

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type Locator } from "@playwright/test";
import { test, expect } from "../../../element-web-test";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
test.describe("Voice & Video room settings tab", () => {
const roomName = "Test room";
test.use({
displayName: "Alice",
});
let settings: Locator;
test.beforeEach(async ({ user, app, page }) => {
// Execute client actions before setting, as the setting will force a reload.
await app.client.createRoom({ name: roomName });
await app.settings.setValue("feature_group_calls", null, SettingLevel.DEVICE, true);
await app.viewRoomByName(roomName);
settings = await app.settings.openRoomSettings("Voice & Video");
});
test(
"should be able to toggle on Element Call in the room",
{ tag: "@screenshot" },
async ({ page, app, user, axe }) => {
await page.setViewportSize({ width: 1024, height: 1400 });
const callToggle = settings.getByLabel("Enable Element Call as an additional calling option in this room");
await callToggle.check();
axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view.
await expect(axe).toHaveNoViolations();
await expect(settings).toMatchScreenshot("room-video-settings.png");
},
);
});

View File

@@ -41,6 +41,18 @@ test.describe("Security user settings tab", () => {
});
});
test("should render the security tab", { tag: "@screenshot" }, async ({ app, page, user }) => {
await page.setViewportSize({ width: 1024, height: 1400 });
const tab = await app.settings.openUserSettings("Security");
await expect(tab).toMatchScreenshot("security-settings-tab.png", {
mask: [
// Contains IM name.
tab.locator("#mx_SetIntegrationManager_BodyText"),
tab.locator("#mx_SetIntegrationManager_ManagerName"),
],
});
});
test("should be able to set an ID server", async ({ app, context, user, page }) => {
const tab = await app.settings.openUserSettings("Security");

View File

@@ -11,6 +11,7 @@ import { test, expect } from "../../element-web-test";
import type { Preset, ICreateRoomOpts } from "matrix-js-sdk/src/matrix";
import { type ElementAppPage } from "../../pages/ElementAppPage";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { UIFeature } from "../../../src/settings/UIFeature";
async function openSpaceCreateMenu(page: Page): Promise<Locator> {
await page.getByRole("button", { name: "Create a space" }).click();
@@ -376,4 +377,68 @@ test.describe("Spaces", () => {
await app.viewSpaceByName("Root Space");
await expect(page.locator(".mx_SpaceRoomView")).toMatchScreenshot("space-room-view.png");
});
test("should render spaces visibility settings", { tag: "@screenshot" }, async ({ page, app, user, axe }) => {
await app.client.createSpace({
name: "My Space",
});
await app.viewSpaceByName("My space");
await page.getByLabel("Settings", { exact: true }).click();
await app.settings.switchTab("Visibility");
axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view.
await expect(axe).toHaveNoViolations();
await expect(page.locator("#mx_tabpanel_SPACE_VISIBILITY_TAB")).toMatchScreenshot(
"space-visibility-settings.png",
);
});
test.describe("Should hide public spaces option if not allowed", () => {
test.use({
config: {
setting_defaults: {
[UIFeature.AllowCreatingPublicSpaces]: false,
},
},
});
test("should disallow creating public rooms", { tag: "@screenshot" }, async ({ page, user, app }) => {
const menu = await openSpaceCreateMenu(page);
await menu
.locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]')
.setInputFiles("playwright/sample-files/riot.png");
await menu.getByRole("textbox", { name: "Name" }).fill("This is a private space");
await expect(menu.getByRole("textbox", { name: "Address" })).not.toBeVisible();
await menu
.getByRole("textbox", { name: "Description" })
.fill("This is a private space because we can't make public ones");
await menu.getByRole("button", { name: "Create" }).click();
await page.getByRole("button", { name: "Me and my teammates" }).click();
// Create the default General & Random rooms, as well as a custom "Projects" room
await expect(page.getByPlaceholder("General")).toBeVisible();
await expect(page.getByPlaceholder("Random")).toBeVisible();
await page.getByPlaceholder("Support").fill("Projects");
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Skip for now" }).click();
// Assert rooms exist in the room list
const roomList = page.getByRole("tree", { name: "Rooms" });
await expect(roomList.getByRole("treeitem", { name: "General", exact: true })).toBeVisible();
await expect(roomList.getByRole("treeitem", { name: "Random", exact: true })).toBeVisible();
await expect(roomList.getByRole("treeitem", { name: "Projects", exact: true })).toBeVisible();
// Assert rooms exist in the space explorer
await expect(
page.locator(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", { hasText: "General" }),
).toBeVisible();
await expect(
page.locator(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", { hasText: "Random" }),
).toBeVisible();
await expect(
page.locator(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", { hasText: "Projects" }),
).toBeVisible();
});
});
});

View File

@@ -30,7 +30,7 @@ async function startDM(app: ElementAppPage, page: Page, name: string): Promise<v
await result.first().click();
// send first message to start DM
const locator = page.getByRole("textbox", { name: "Send an unencrypted message…" });
const locator = page.getByRole("textbox", { name: "Send a message…" });
await expect(locator).toBeFocused();
await locator.fill("Hey!");
await locator.press("Enter");
@@ -260,7 +260,7 @@ test.describe("Spotlight", () => {
// Send first message to actually start DM
await expect(roomHeaderName(page)).toHaveText(bot2.credentials.displayName);
const locator = page.getByRole("textbox", { name: "Send an unencrypted message…" });
const locator = page.getByRole("textbox", { name: "Send a message…" });
await locator.fill("Hey!");
await locator.press("Enter");

View File

@@ -0,0 +1,98 @@
/*
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";
const DEMO_WIDGET_ID = "demo-widget-id";
const DEMO_WIDGET_NAME = "Demo Widget";
const DEMO_WIDGET_TYPE = "demo";
const ROOM_NAME = "Demo";
const DEMO_WIDGET_HTML = `
<html lang="en">
<head>
<title>Demo Widget</title>
<script>
let sendEventCount = 0
window.onmessage = ev => {
if (ev.data.action === 'capabilities') {
window.parent.postMessage(Object.assign({
response: {
capabilities: [
"org.matrix.msc2762.timeline:*",
"org.matrix.msc2762.receive.state_event:m.room.topic",
"org.matrix.msc2762.send.event:net.widget_echo"
]
},
}, ev.data), '*');
}
};
</script>
</head>
</html>
`;
test.describe("Widger permissions dialog", () => {
test.use({
displayName: "Mike",
});
let demoWidgetUrl: string;
test.beforeEach(async ({ webserver }) => {
demoWidgetUrl = webserver.start(DEMO_WIDGET_HTML);
});
test(
"should be updated if user is re-invited into the room with updated state event",
{ tag: "@screenshot" },
async ({ page, app, user, axe }) => {
const roomId = await app.client.createRoom({
name: ROOM_NAME,
});
// setup widget via state event
await app.client.sendStateEvent(
roomId,
"im.vector.modular.widgets",
{
id: DEMO_WIDGET_ID,
creatorUserId: "somebody",
type: DEMO_WIDGET_TYPE,
name: DEMO_WIDGET_NAME,
url: demoWidgetUrl,
},
DEMO_WIDGET_ID,
);
// set initial layout
await app.client.sendStateEvent(
roomId,
"io.element.widgets.layout",
{
widgets: {
[DEMO_WIDGET_ID]: {
container: "top",
index: 1,
width: 100,
height: 0,
},
},
},
"",
);
// open the room
await app.viewRoomByName(ROOM_NAME);
axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view.
await expect(axe).toHaveNoViolations();
await expect(page.locator(".mx_WidgetCapabilitiesPromptDialog")).toMatchScreenshot(
"widget-capabilites-prompt.png",
);
},
);
});

View File

@@ -51,9 +51,9 @@ export class ElementAppPage {
/**
* Open room creation dialog.
*/
public async openCreateRoomDialog(): Promise<Locator> {
public async openCreateRoomDialog(roomKindname: "New room" | "New video room" = "New room"): Promise<Locator> {
await this.page.getByRole("button", { name: "Add room", exact: true }).click();
await this.page.getByRole("menuitem", { name: "New room", exact: true }).click();
await this.page.getByRole("menuitem", { name: roomKindname, exact: true }).click();
return this.page.locator(".mx_CreateRoomDialog");
}

View File

@@ -43,7 +43,7 @@ export class Settings {
* @param {*} value The new value of the setting, may be null.
* @return {Promise} Resolves when the setting has been changed.
*/
public async setValue(settingName: string, roomId: string, level: SettingLevel, value: any): Promise<void> {
public async setValue(settingName: string, roomId: string | null, level: SettingLevel, value: any): Promise<void> {
return this.page.evaluate<
Promise<void>,
{

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -10,7 +10,7 @@ import {
type StartedPostgreSqlContainer,
} from "@element-hq/element-web-playwright-common/lib/testcontainers";
const TAG = "main@sha256:430b1f00e74c3f89f078670f676b4333f6bbe5a339962344b3ae84e99e9bcd7f";
const TAG = "main@sha256:64b638f2c0ddd7aa0ddcbc39d21cdf3cedab91508b5d7953e2e85c9901ac5b26";
/**
* MatrixAuthenticationServiceContainer which freezes the docker digest to

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
const TAG = "develop@sha256:8ce4c1a466e1e32bcffde390250a6785527d235436ae42afa9bf94d2a9288746";
const TAG = "develop@sha256:1784031f7b07de2abffe5b823b59be87a1fb1329a18ae3fc87e66f00f8b79fab";
/**
* SynapseContainer which freezes the docker digest to stabilise tests,

View File

@@ -6,13 +6,6 @@ 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.
*/
.mx_SetupEncryptionBody_reset {
color: $light-fg-color;
margin-top: $font-14px;
.mx_SetupEncryptionBody_reset_link {
&.mx_AccessibleButton_kind_link_inline {
color: $alert;
}
}
.mx_SetupEncryptionBody {
width: 600px;
}

View File

@@ -19,7 +19,6 @@ Please see LICENSE files in the repository root for full details.
display: flex;
margin: 100px auto auto;
border-radius: 4px;
box-shadow: 0 2px 4px 0 rgb(0, 0, 0, 0.33);
background-color: $authpage-modal-bg-color;
@media only screen and (max-height: 768px) {
@@ -29,4 +28,9 @@ Please see LICENSE files in the repository root for full details.
@media only screen and (max-width: 480px) {
margin-top: 0;
}
/* Apply a blurred shadow around the modal */
&.mx_AuthPage_modal_withBlur {
box-shadow: 0 2px 4px 0 rgb(0, 0, 0, 0.33);
}
}

View File

@@ -8,11 +8,10 @@ Please see LICENSE files in the repository root for full details.
*/
.mx_CompleteSecurityBody {
width: 600px;
color: $authpage-primary-color;
background-color: $background;
border-radius: 4px;
padding: 20px;
padding: 20px 20px 60px 20px;
box-sizing: border-box;
h2 {

View File

@@ -24,6 +24,12 @@ Please see LICENSE files in the repository root for full details.
}
}
.mx_DevTools_toolHeading {
color: var(--cpd-color-text-secondary);
font-weight: var(--cpd-font-weight-semibold);
font-size: var(--cpd-font-size-heading-sm);
}
.mx_DevTools_content {
overflow-y: auto;
}

View File

@@ -13,7 +13,7 @@
padding: var(--cpd-space-1x) var(--cpd-space-3x) var(--cpd-space-1x) var(--cpd-space-1x);
font: var(--cpd-font-body-xs-medium);
background-color: var(--cpd-color-alpha-gray-200);
background-color: var(--cpd-color-bg-subtle-secondary);
color: var(--cpd-color-text-secondary);
border-radius: 99px;

View File

@@ -32,4 +32,8 @@
transform: rotate(180deg);
}
}
.mx_RoomListHeaderView_ReleaseAnnouncementAnchor {
display: inline-flex;
}
}

View File

@@ -208,7 +208,7 @@ Please see LICENSE files in the repository root for full details.
margin-right: 12px;
}
h3 {
h1 {
font-size: inherit;
margin: 0;
}

View File

@@ -10,6 +10,7 @@ Please see LICENSE files in the repository root for full details.
/* These are set in Javascript */
--avatar-letter: "";
--avatar-background: unset;
--avatar-color: unset;
--placeholder: "";
position: relative;
@@ -54,6 +55,8 @@ Please see LICENSE files in the repository root for full details.
span.mx_UserPill,
span.mx_RoomPill,
span.mx_SpacePill {
display: inline-flex;
align-items: center;
user-select: all;
position: relative;
cursor: unset; /* We don't want indicate clickability */

View File

@@ -30,6 +30,13 @@
text-align: center;
}
}
/* extra class for specifying that we don't need a border */
&.mx_EncryptionCard_noBorder {
border: 0px none;
box-shadow: none;
padding: 0px;
}
}
.mx_EncryptionCard_buttons {

Binary file not shown.

Binary file not shown.

View File

@@ -13,14 +13,18 @@ import DMRoomMap from "./utils/DMRoomMap";
import { mediaFromMxc } from "./customisations/Media";
import { isLocalRoom } from "./utils/localRoom/isLocalRoom";
import { getFirstGrapheme } from "./utils/strings";
import ThemeWatcher from "./settings/watchers/ThemeWatcher";
/**
* Hardcoded from the Compound colors.
* Shade for background as defined in the compound web implementation
* https://github.com/vector-im/compound-web/blob/main/src/components/Avatar
*/
const AVATAR_BG_COLORS = ["#e9f2ff", "#faeefb", "#e3f7ed", "#ffecf0", "#ffefe4", "#e3f5f8", "#f1efff", "#e0f8d9"];
const AVATAR_TEXT_COLORS = ["#043894", "#671481", "#004933", "#7e0642", "#850000", "#004077", "#4c05b5", "#004b00"];
const AVATAR_BG_LIGHT_COLORS = ["#e0f8d9", "#e3f5f8", "#faeefb", "#f1efff", "#ffecf0", "#ffefe4"];
const AVATAR_TEXT_LIGHT_COLORS = ["#005f00", "#00548c", "#822198", "#5d26cd", "#9f0850", "#9b2200"];
const AVATAR_BG_DARK_COLORS = ["#002600", "#001b4e", "#37004e", "#22006a", "#450018", "#470000"];
const AVATAR_TEXT_DARK_COLORS = ["#56c02c", "#21bacd", "#d991de", "#ad9cfe", "#fe84a2", "#f6913d"];
// Not to be used for BaseAvatar urls as that has similar default avatar fallback already
export function avatarUrlForMember(
@@ -42,6 +46,13 @@ export function avatarUrlForMember(
return url;
}
/**
* Determines if the current theme is dark
*/
function isDarkTheme(): boolean {
return new ThemeWatcher().getEffectiveTheme() === "dark";
}
/**
* Determines the HEX color to use in the avatar pills
* @param id the user or room ID
@@ -51,7 +62,8 @@ export function getAvatarTextColor(id: string): string {
// eslint-disable-next-line react-hooks/rules-of-hooks
const index = useIdColorHash(id);
return AVATAR_TEXT_COLORS[index - 1];
// Use light colors by default
return isDarkTheme() ? AVATAR_TEXT_DARK_COLORS[index - 1] : AVATAR_TEXT_LIGHT_COLORS[index - 1];
}
export function avatarUrlForUser(
@@ -103,7 +115,10 @@ export function defaultAvatarUrlForString(s: string): string {
// overwritten color value in custom themes
const cssVariable = `--avatar-background-colors_${colorIndex}`;
const cssValue = getComputedStyle(document.body).getPropertyValue(cssVariable);
const color = cssValue || AVATAR_BG_COLORS[colorIndex - 1];
// Light colors are the default
const color =
cssValue || isDarkTheme() ? AVATAR_BG_DARK_COLORS[colorIndex - 1] : AVATAR_BG_LIGHT_COLORS[colorIndex - 1];
let dataUrl = colorToDataURLCache.get(color);
if (!dataUrl) {
// validate color as this can come from account_data

View File

@@ -63,7 +63,12 @@ import { blobIsAnimated } from "./utils/Image.ts";
const PHYS_HIDPI = [0x00, 0x00, 0x16, 0x25, 0x00, 0x00, 0x16, 0x25, 0x01];
export class UploadCanceledError extends Error {}
export class UploadFailedError extends Error {}
export class UploadFailedError extends Error {
public constructor(cause: any) {
super();
this.cause = cause;
}
}
interface IMediaConfig {
"m.upload.size"?: number;
@@ -367,7 +372,7 @@ export async function uploadFile(
} catch (e) {
if (abortController.signal.aborted) throw new UploadCanceledError();
console.error("Failed to upload file", e);
throw new UploadFailedError();
throw new UploadFailedError(e);
}
if (abortController.signal.aborted) throw new UploadCanceledError();
@@ -386,7 +391,7 @@ export async function uploadFile(
} catch (e) {
if (abortController.signal.aborted) throw new UploadCanceledError();
console.error("Failed to upload file", e);
throw new UploadFailedError();
throw new UploadFailedError(e);
}
if (abortController.signal.aborted) throw new UploadCanceledError();
// If the attachment isn't encrypted then include the URL directly.
@@ -638,15 +643,18 @@ export default class ContentMessages {
dis.dispatch<UploadFinishedPayload>({ action: Action.UploadFinished, upload });
dis.dispatch({ action: "message_sent" });
} catch (error) {
// Unwrap UploadFailedError to get the underlying error
const unwrappedError = error instanceof UploadFailedError && error.cause ? error.cause : error;
// 413: File was too big or upset the server in some way:
// clear the media size limit so we fetch it again next time we try to upload
if (error instanceof HTTPError && error.httpStatus === 413) {
if (unwrappedError instanceof HTTPError && unwrappedError.httpStatus === 413) {
this.mediaConfig = null;
}
if (!upload.cancelled) {
let desc = _t("upload_failed_generic", { fileName: upload.fileName });
if (error instanceof HTTPError && error.httpStatus === 413) {
if (unwrappedError instanceof HTTPError && unwrappedError.httpStatus === 413) {
desc = _t("upload_failed_size", {
fileName: upload.fileName,
});

View File

@@ -486,13 +486,27 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
private performCustomEventHandling(ev: MatrixEvent): void {
const cli = MatrixClientPeg.safeGet();
const room = cli.getRoom(ev.getRoomId());
const type = ev.getType();
const thisUserHasConnectedDevice =
room && MatrixRTCSession.callMembershipsForRoom(room).some((m) => m.sender === cli.getUserId());
if (EventType.GroupCallMemberPrefix === type && thisUserHasConnectedDevice) {
const content = ev.getContent();
if (typeof content.call_id !== "string") {
logger.warn(
"Received malformatted GroupCallMemberPrefix event. Did not contain 'call_id' of type 'string'",
);
return;
}
// One of our devices has joined the call, so dismiss it.
ToastStore.sharedInstance().dismissToast(getIncomingCallToastKey(content.call_id, room.roomId));
}
// Check maximum age (<= 15 seconds) of a call notify event that will trigger a ringing notification
if (EventType.CallNotify === ev.getType() && (ev.getAge() ?? 0) < 15000 && !thisUserHasConnectedDevice) {
else if (EventType.CallNotify === type && (ev.getAge() ?? 0) < 15000 && !thisUserHasConnectedDevice) {
const content = ev.getContent();
const roomId = ev.getRoomId();
if (typeof content.call_id !== "string") {
logger.warn("Received malformatted CallNotify event. Did not contain 'call_id' of type 'string'");
return;

View File

@@ -14,7 +14,6 @@ import { logger as rootLogger } from "matrix-js-sdk/src/logger";
import Modal from "./Modal";
import { MatrixClientPeg } from "./MatrixClientPeg";
import { _t } from "./languageHandler";
import { isSecureBackupRequired } from "./utils/WellKnownUtils";
import AccessSecretStorageDialog, {
type KeyParams,
} from "./components/views/dialogs/security/AccessSecretStorageDialog";
@@ -232,15 +231,6 @@ async function doAccessSecretStorage(func: () => Promise<void>, opts: AccessSecr
undefined,
/* priority = */ false,
/* static = */ true,
/* options = */ {
onBeforeClose: async (reason): Promise<boolean> => {
// If Secure Backup is required, you cannot leave the modal.
if (reason === "backgroundClick") {
return !isSecureBackupRequired(cli);
}
return true;
},
},
);
const [confirmed] = await finished;
if (!confirmed) {

View File

@@ -9,6 +9,7 @@
import { TimelineRenderingType } from "../contexts/RoomContext";
import { Action } from "../dispatcher/actions";
import defaultDispatcher from "../dispatcher/dispatcher";
import SettingsStore from "../settings/SettingsStore";
export const enum Landmark {
// This is the space/home button in the left panel.
@@ -72,10 +73,16 @@ export class LandmarkNavigation {
const landmarkToDomElementMap: Record<Landmark, () => HTMLElement | null | undefined> = {
[Landmark.ACTIVE_SPACE_BUTTON]: () => document.querySelector<HTMLElement>(".mx_SpaceButton_active"),
[Landmark.ROOM_SEARCH]: () => document.querySelector<HTMLElement>(".mx_RoomSearch"),
[Landmark.ROOM_SEARCH]: () =>
SettingsStore.getValue("feature_new_room_list")
? document.querySelector<HTMLElement>(".mx_RoomListSearch_search")
: document.querySelector<HTMLElement>(".mx_RoomSearch"),
[Landmark.ROOM_LIST]: () =>
document.querySelector<HTMLElement>(".mx_RoomTile_selected") ||
document.querySelector<HTMLElement>(".mx_RoomTile"),
SettingsStore.getValue("feature_new_room_list")
? document.querySelector<HTMLElement>(".mx_RoomListItemView_selected") ||
document.querySelector<HTMLElement>(".mx_RoomListItemView")
: document.querySelector<HTMLElement>(".mx_RoomTile_selected") ||
document.querySelector<HTMLElement>(".mx_RoomTile"),
[Landmark.MESSAGE_COMPOSER_OR_HOME]: () => {
const isComposerOpen = !!document.querySelector(".mx_MessageComposer");

View File

@@ -25,11 +25,6 @@ import StyledRadioButton from "../../../../components/views/elements/StyledRadio
import AccessibleButton from "../../../../components/views/elements/AccessibleButton";
import DialogButtons from "../../../../components/views/elements/DialogButtons";
import InlineSpinner from "../../../../components/views/elements/InlineSpinner";
import {
getSecureBackupSetupMethods,
isSecureBackupRequired,
SecureBackupSetupMethod,
} from "../../../../utils/WellKnownUtils";
import { ModuleRunner } from "../../../../modules/ModuleRunner";
import type Field from "../../../../components/views/elements/Field";
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
@@ -39,6 +34,11 @@ import { type IValidationResult } from "../../../../components/views/elements/Va
import PassphraseConfirmField from "../../../../components/views/auth/PassphraseConfirmField";
import { initialiseDehydrationIfEnabled } from "../../../../utils/device/dehydration";
enum SecureBackupSetupMethod {
Key = "key",
Passphrase = "passphrase",
}
// I made a mistake while converting this and it has to be fixed!
enum Phase {
Loading = "loading",
@@ -68,7 +68,6 @@ interface IState {
downloaded: boolean;
setPassphrase: boolean;
canSkip: boolean;
passPhraseKeySelected: string;
error?: boolean;
}
@@ -93,16 +92,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
public constructor(props: IProps) {
super(props);
const cli = MatrixClientPeg.safeGet();
let passPhraseKeySelected: SecureBackupSetupMethod;
const setupMethods = getSecureBackupSetupMethods(cli);
if (setupMethods.includes(SecureBackupSetupMethod.Key)) {
passPhraseKeySelected = SecureBackupSetupMethod.Key;
} else {
passPhraseKeySelected = SecureBackupSetupMethod.Passphrase;
}
const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.createSecretStorageKey();
const phase = keyFromCustomisations ? Phase.Loading : Phase.ChooseKeyPassphrase;
@@ -114,8 +103,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
copied: false,
downloaded: false,
setPassphrase: false,
canSkip: !isSecureBackupRequired(cli),
passPhraseKeySelected,
passPhraseKeySelected: SecureBackupSetupMethod.Key,
};
}
@@ -391,11 +379,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
}
private renderPhaseChooseKeyPassphrase(): JSX.Element {
const setupMethods = getSecureBackupSetupMethods(MatrixClientPeg.safeGet());
const optionKey = setupMethods.includes(SecureBackupSetupMethod.Key) ? this.renderOptionKey() : null;
const optionPassphrase = setupMethods.includes(SecureBackupSetupMethod.Passphrase)
? this.renderOptionPassphrase()
: null;
const optionKey = this.renderOptionKey();
const optionPassphrase = this.renderOptionPassphrase();
return (
<form onSubmit={this.onChooseKeyPassphraseFormSubmit}>
@@ -410,7 +395,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
primaryButton={_t("action|continue")}
onPrimaryButtonClick={this.onChooseKeyPassphraseFormSubmit}
onCancel={this.onCancelClick}
hasCancel={this.state.canSkip}
/>
</form>
);
@@ -601,7 +585,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
<DialogButtons
primaryButton={_t("action|retry")}
onPrimaryButtonClick={this.onLoadRetryClick}
hasCancel={this.state.canSkip}
onCancel={this.onCancel}
/>
</div>
@@ -672,7 +655,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
<DialogButtons
primaryButton={_t("action|retry")}
onPrimaryButtonClick={this.bootstrapSecretStorage}
hasCancel={this.state.canSkip}
onCancel={this.onCancel}
/>
</div>

View File

@@ -20,6 +20,7 @@ import { DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES } from "./consts";
import { PlaybackEncoder } from "../PlaybackEncoder";
export enum PlaybackState {
Preparing = "preparing", // preparing to decode
Decoding = "decoding",
Stopped = "stopped", // no progress on timeline
Paused = "paused", // some progress on timeline
@@ -146,6 +147,8 @@ export class Playback extends EventEmitter implements IDestroyable, PlaybackInte
return;
}
this.state = PlaybackState.Preparing;
// The point where we use an audio element is fairly arbitrary, though we don't want
// it to be too low. As of writing, voice messages want to show a waveform but audio
// messages do not. Using an audio element means we can't show a waveform preview, so

View File

@@ -72,7 +72,11 @@ export default class EmbeddedPage extends React.PureComponent<IProps, IState> {
return;
}
let body = (await res.text()).replace(/_t\(['"]([\s\S]*?)['"]\)/gm, (match, g1) => this.translate(g1));
// Replace '," and HTML encoded variants
let body = (await res.text()).replace(
/_t\((?:['"]|(?:&#(?:34|27);))([\s\S]*?)(?:['"]|(?:&#(?:34|27);))\)/gm,
(match, g1) => this.translate(g1),
);
if (this.props.replaceMap) {
Object.keys(this.props.replaceMap).forEach((key) => {

View File

@@ -133,6 +133,7 @@ import { PinnedMessageBanner } from "../views/rooms/PinnedMessageBanner";
import { ScopedRoomContextProvider, useScopedRoomContext } from "../../contexts/ScopedRoomContext";
import { DeclineAndBlockInviteDialog } from "../views/dialogs/DeclineAndBlockInviteDialog";
import { type FocusMessageSearchPayload } from "../../dispatcher/payloads/FocusMessageSearchPayload.ts";
import { isRoomEncrypted } from "../../hooks/useIsEncrypted";
const DEBUG = false;
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
@@ -257,6 +258,7 @@ interface LocalRoomViewProps {
roomView: RefObject<HTMLElement | null>;
onFileDrop: (dataTransfer: DataTransfer) => Promise<void>;
mainSplitContentType: MainSplitContentType;
e2eStatus?: E2EStatus;
}
/**
@@ -304,6 +306,7 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement {
} else {
composer = (
<MessageComposer
e2eStatus={props.e2eStatus}
room={props.localRoom}
resizeNotifier={props.resizeNotifier}
permalinkCreator={props.permalinkCreator}
@@ -1397,10 +1400,13 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
private async getIsRoomEncrypted(roomId = this.state.roomId): Promise<boolean> {
const crypto = this.context.client?.getCrypto();
if (!crypto || !roomId) return false;
if (!roomId) return false;
return await crypto.isEncryptionEnabledInRoom(roomId);
const room = this.context.client?.getRoom(roomId);
const crypto = this.context.client?.getCrypto();
if (!room || !crypto) return false;
return isRoomEncrypted(room, crypto);
}
private async calculateRecommendedVersion(room: Room): Promise<void> {
@@ -2061,6 +2067,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
return (
<ScopedRoomContextProvider {...this.state}>
<LocalRoomView
e2eStatus={this.state.e2eStatus}
localRoom={localRoom}
resizeNotifier={this.props.resizeNotifier}
permalinkCreator={this.permalinkCreator}

View File

@@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { Glass } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import { SetupEncryptionStore, Phase } from "../../../stores/SetupEncryptionStore";
@@ -22,15 +23,17 @@ interface IProps {
interface IState {
phase?: Phase;
lostKeys: boolean;
}
/**
* Prompts the user to verify their device when they first log in.
*/
export default class CompleteSecurity extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
const store = SetupEncryptionStore.sharedInstance();
store.start();
this.state = { phase: store.phase, lostKeys: store.lostKeys() };
this.state = { phase: store.phase };
}
public componentDidMount(): void {
@@ -40,7 +43,7 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
private onStoreUpdate = (): void => {
const store = SetupEncryptionStore.sharedInstance();
this.setState({ phase: store.phase, lostKeys: store.lostKeys() });
this.setState({ phase: store.phase });
};
private onSkipClick = (): void => {
@@ -55,20 +58,14 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
}
public render(): React.ReactNode {
const { phase, lostKeys } = this.state;
const { phase } = this.state;
let icon;
let title;
if (phase === Phase.Loading) {
return null;
} else if (phase === Phase.Intro) {
if (lostKeys) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("encryption|verification|after_new_login|unable_to_verify");
} else {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("encryption|verification|after_new_login|verify_this_device");
}
// We don't specify an icon nor title since `SetupEncryptionBody` provides its own
} else if (phase === Phase.Done) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_verified" />;
title = _t("encryption|verification|after_new_login|device_verified");
@@ -98,17 +95,19 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
}
return (
<AuthPage>
<CompleteSecurityBody>
<h1 className="mx_CompleteSecurity_header">
{icon}
{title}
{skipButton}
</h1>
<div className="mx_CompleteSecurity_body">
<SetupEncryptionBody onFinished={this.props.onFinished} />
</div>
</CompleteSecurityBody>
<AuthPage addBlur={false}>
<Glass className="mx_Dialog_border">
<CompleteSecurityBody>
<h1 className="mx_CompleteSecurity_header">
{icon}
{title}
{skipButton}
</h1>
<div className="mx_CompleteSecurity_body">
<SetupEncryptionBody onFinished={this.props.onFinished} allowLogout={true} />
</div>
</CompleteSecurityBody>
</Glass>
</AuthPage>
);
}

View File

@@ -9,7 +9,9 @@ Please see LICENSE files in the repository root for full details.
import React, { type JSX } from "react";
import { type KeyBackupInfo, type VerificationRequest } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger";
import { type SecretStorageKeyDescription } from "matrix-js-sdk/src/secret-storage";
import DevicesIcon from "@vector-im/compound-design-tokens/assets/web/icons/devices";
import LockIcon from "@vector-im/compound-design-tokens/assets/web/icons/lock-solid";
import { Button } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
@@ -17,25 +19,38 @@ import Modal from "../../../Modal";
import VerificationRequestDialog from "../../views/dialogs/VerificationRequestDialog";
import { SetupEncryptionStore, Phase } from "../../../stores/SetupEncryptionStore";
import EncryptionPanel from "../../views/right_panel/EncryptionPanel";
import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton";
import AccessibleButton from "../../views/elements/AccessibleButton";
import Spinner from "../../views/elements/Spinner";
import { ResetIdentityDialog } from "../../views/dialogs/ResetIdentityDialog";
function keyHasPassphrase(keyInfo: SecretStorageKeyDescription): boolean {
return Boolean(keyInfo.passphrase && keyInfo.passphrase.salt && keyInfo.passphrase.iterations);
}
import { EncryptionCard } from "../../views/settings/encryption/EncryptionCard";
import { EncryptionCardButtons } from "../../views/settings/encryption/EncryptionCardButtons";
import { EncryptionCardEmphasisedContent } from "../../views/settings/encryption/EncryptionCardEmphasisedContent";
import ExternalLink from "../../views/elements/ExternalLink";
import dispatcher from "../../../dispatcher/dispatcher";
interface IProps {
onFinished: () => void;
/**
* Offer the user an option to log out, instead of setting up encryption.
*
* This is used when this component is shown when the user is initially
* prompted to set up encryption, before the user is shown the main chat
* interface.
*
* Defaults to `false` if omitted.
*/
allowLogout?: boolean;
}
interface IState {
phase?: Phase;
verificationRequest: VerificationRequest | null;
backupInfo: KeyBackupInfo | null;
lostKeys: boolean;
}
/**
* Component to set up encryption by verifying the current device.
*/
export default class SetupEncryptionBody extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
@@ -48,7 +63,6 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
// Because of the latter, it lives in the state.
verificationRequest: store.verificationRequest,
backupInfo: store.backupInfo,
lostKeys: store.lostKeys(),
};
}
@@ -67,7 +81,6 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
phase: store.phase,
verificationRequest: store.verificationRequest,
backupInfo: store.backupInfo,
lostKeys: store.lostKeys(),
});
};
@@ -112,8 +125,8 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
store.returnAfterSkip();
};
private onResetClick = (ev: ButtonEvent): void => {
ev.preventDefault();
private onCantConfirmClick = (): void => {
const store = SetupEncryptionStore.sharedInstance();
Modal.createDialog(ResetIdentityDialog, {
onReset: () => {
// The user completed the reset process - close this dialog
@@ -121,10 +134,14 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
const store = SetupEncryptionStore.sharedInstance();
store.done();
},
variant: "confirm",
variant: store.lostKeys() ? "no_verification_method" : "confirm",
});
};
private onSignOutClick = (): void => {
dispatcher.dispatch({ action: "logout" });
};
private onDoneClick = (): void => {
const store = SetupEncryptionStore.sharedInstance();
store.done();
@@ -136,7 +153,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
public render(): React.ReactNode {
const cli = MatrixClientPeg.safeGet();
const { phase, lostKeys } = this.state;
const { phase } = this.state;
if (this.state.verificationRequest && cli.getUser(this.state.verificationRequest.otherUserId)) {
return (
@@ -149,69 +166,59 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
/>
);
} else if (phase === Phase.Intro) {
if (lostKeys) {
return (
<div>
<p>{_t("encryption|verification|no_key_or_device")}</p>
const store = SetupEncryptionStore.sharedInstance();
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton kind="primary" onClick={this.onResetClick}>
{_t("encryption|verification|reset_proceed_prompt")}
</AccessibleButton>
</div>
</div>
);
} else {
const store = SetupEncryptionStore.sharedInstance();
let recoveryKeyPrompt;
if (store.keyInfo && keyHasPassphrase(store.keyInfo)) {
recoveryKeyPrompt = _t("encryption|verification|verify_using_key_or_phrase");
} else if (store.keyInfo) {
recoveryKeyPrompt = _t("encryption|verification|verify_using_key");
}
let useRecoveryKeyButton;
if (recoveryKeyPrompt) {
useRecoveryKeyButton = (
<AccessibleButton kind="primary" onClick={this.onUsePassphraseClick}>
{recoveryKeyPrompt}
</AccessibleButton>
);
}
let verifyButton;
if (store.hasDevicesToVerifyAgainst) {
verifyButton = (
<AccessibleButton kind="primary" onClick={this.onVerifyClick}>
{_t("encryption|verification|verify_using_device")}
</AccessibleButton>
);
}
return (
<div>
<p>{_t("encryption|verification|verification_description")}</p>
<div className="mx_CompleteSecurity_actionRow">
{verifyButton}
{useRecoveryKeyButton}
</div>
<div className="mx_SetupEncryptionBody_reset">
{_t("encryption|reset_all_button", undefined, {
a: (sub) => (
<AccessibleButton
kind="link_inline"
className="mx_SetupEncryptionBody_reset_link"
onClick={this.onResetClick}
>
{sub}
</AccessibleButton>
),
})}
</div>
</div>
let verifyButton;
if (store.hasDevicesToVerifyAgainst) {
verifyButton = (
<Button kind="primary" onClick={this.onVerifyClick}>
<DevicesIcon /> {_t("encryption|verification|use_another_device")}
</Button>
);
}
let useRecoveryKeyButton;
if (store.keyInfo) {
useRecoveryKeyButton = (
<Button kind="primary" onClick={this.onUsePassphraseClick}>
{_t("encryption|verification|use_recovery_key")}
</Button>
);
}
let signOutButton;
if (this.props.allowLogout) {
signOutButton = (
<Button kind="tertiary" onClick={this.onSignOutClick}>
{_t("action|sign_out")}
</Button>
);
}
return (
<EncryptionCard
title={_t("encryption|verification|confirm_identity_title")}
Icon={LockIcon}
className="mx_EncryptionCard_noBorder mx_SetupEncryptionBody"
>
<EncryptionCardEmphasisedContent>
<span>{_t("encryption|verification|confirm_identity_description")}</span>
<span>
<ExternalLink href="https://element.io/help#encryption-device-verification">
{_t("action|learn_more")}
</ExternalLink>
</span>
</EncryptionCardEmphasisedContent>
<EncryptionCardButtons>
{verifyButton}
{useRecoveryKeyButton}
<Button kind="secondary" onClick={this.onCantConfirmClick}>
{_t("encryption|verification|cant_confirm")}
</Button>
{signOutButton}
</EncryptionCardButtons>
</EncryptionCard>
);
} else if (phase === Phase.Done) {
let message: JSX.Element;
if (this.state.backupInfo) {

View File

@@ -285,7 +285,10 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
return (
<Virtuoso
tabIndex={props.tabIndex || undefined} // We don't need to focus the container, so leave it undefined by default
// note that either the container of direct children must be focusable to be axe
// compliant, so we leave tabIndex as the default so the container can be focused
// (virtuoso wraps the children inside another couple of elements so setting it
// on those doesn't seem to work, unfortunately)
ref={virtuosoHandleRef}
scrollerRef={scrollerRef}
onKeyDown={keyDownCallback}

View File

@@ -79,12 +79,30 @@ export function useKeyStoragePanelViewModel(): KeyStoragePanelState {
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();
const childLogger = logger.getChild("[enable key storage]");
childLogger.info("User requested enabling key storage");
let currentKeyBackup = await crypto.checkKeyBackupAndEnable();
if (currentKeyBackup) {
logger.info(
`Existing key backup is present. version: ${currentKeyBackup.backupInfo.version}`,
currentKeyBackup.trustInfo,
);
// Check if the current key backup can be used. Either of these properties causes the key backup to be used.
if (currentKeyBackup.trustInfo.trusted || currentKeyBackup.trustInfo.matchesDecryptionKey) {
logger.info("Existing key backup can be used");
} else {
logger.warn("Existing key backup cannot be used, creating new backup");
// There aren't any *usable* backups, so we need to create a new one.
currentKeyBackup = null;
}
} else {
logger.info("No existing key backup versions are present, creating new backup");
}
// If there is no usable key backup on the server, create one.
// `resetKeyBackup` will delete any existing backup, so we only do this if there is no usable backup.
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();
@@ -93,6 +111,7 @@ export function useKeyStoragePanelViewModel(): KeyStoragePanelState {
// 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 {
logger.info("User requested disabling key backup");
// This method will delete the key backup as well as server side recovery keys and other
// server-side crypto data.
await crypto.disableKeyStorage();

View File

@@ -74,7 +74,11 @@ export default class RecordingPlayback extends AudioPlayerBase<IProps> {
}
return (
<div className="mx_MediaBody mx_VoiceMessagePrimaryContainer" onKeyDown={this.onKeyDown}>
<div
className="mx_MediaBody mx_VoiceMessagePrimaryContainer"
onKeyDown={this.onKeyDown}
data-testid="recording-playback"
>
<PlayPauseButton
playback={this.props.playback}
playbackPhase={this.state.playbackPhase}

View File

@@ -8,11 +8,22 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import classNames from "classnames";
import SdkConfig from "../../../SdkConfig";
import AuthFooter from "./AuthFooter";
export default class AuthPage extends React.PureComponent<React.PropsWithChildren> {
interface IProps {
/**
* Whether to add a blurred shadow around the modal.
*
* If the modal component provides its own shadow or blurring, this can be
* disabled. Defaults to `true`.
*/
addBlur?: boolean;
}
export default class AuthPage extends React.PureComponent<React.PropsWithChildren<IProps>> {
private static welcomeBackgroundUrl?: string;
// cache the url as a static to prevent it changing without refreshing
@@ -58,14 +69,26 @@ export default class AuthPage extends React.PureComponent<React.PropsWithChildre
const modalContentStyle: React.CSSProperties = {
display: "flex",
zIndex: 1,
background: "rgba(255, 255, 255, 0.59)",
borderRadius: "8px",
};
let modalBlur;
if (this.props.addBlur !== false) {
// Blur out the background: add a `div` which covers the content behind the modal,
// and blurs it out, and make the modal's background semitransparent.
modalBlur = <div className="mx_AuthPage_modalBlur" style={blurStyle} />;
modalContentStyle.background = "rgba(255, 255, 255, 0.59)";
}
const modalClasses = classNames({
mx_AuthPage_modal: true,
mx_AuthPage_modal_withBlur: this.props.addBlur !== false,
});
return (
<div className="mx_AuthPage" style={pageStyle}>
<div className="mx_AuthPage_modal" style={modalStyle}>
<div className="mx_AuthPage_modalBlur" style={blurStyle} />
<div className={modalClasses} style={modalStyle}>
{modalBlur}
<div className="mx_AuthPage_modalContent" style={modalContentStyle}>
{this.props.children}
</div>

View File

@@ -37,6 +37,8 @@ const WidgetAvatar: React.FC<IProps> = ({ app, className, size = "20px", ...prop
return (
<BaseAvatar
{...props}
// Span elements cannot have a label
role="img"
name={app.id}
className={classNames("mx_WidgetAvatar", className)}
// MSC2765

View File

@@ -26,6 +26,7 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { privateShouldBeEncrypted } from "../../../utils/rooms";
import SettingsStore from "../../../settings/SettingsStore";
import LabelledCheckbox from "../elements/LabelledCheckbox";
import { UIFeature } from "../../../settings/UIFeature";
interface IProps {
type?: RoomType;
@@ -83,6 +84,8 @@ interface IState {
export default class CreateRoomDialog extends React.Component<IProps, IState> {
private readonly askToJoinEnabled: boolean;
private readonly advancedSettingsEnabled: boolean;
private readonly allowCreatingPublicRooms: boolean;
private readonly supportsRestricted: boolean;
private nameField = createRef<Field>();
private aliasField = createRef<RoomAliasField>();
@@ -91,10 +94,14 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
super(props);
this.askToJoinEnabled = SettingsStore.getValue("feature_ask_to_join");
this.advancedSettingsEnabled = SettingsStore.getValue(UIFeature.AdvancedSettings);
this.allowCreatingPublicRooms = SettingsStore.getValue(UIFeature.AllowCreatingPublicRooms);
this.supportsRestricted = !!this.props.parentSpace;
const defaultPublic = this.allowCreatingPublicRooms && this.props.defaultPublic;
let joinRule = JoinRule.Invite;
if (this.props.defaultPublic) {
if (defaultPublic) {
joinRule = JoinRule.Public;
} else if (this.supportsRestricted) {
joinRule = JoinRule.Restricted;
@@ -102,7 +109,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
const cli = MatrixClientPeg.safeGet();
this.state = {
isPublicKnockRoom: this.props.defaultPublic || false,
isPublicKnockRoom: defaultPublic || false,
isEncrypted: this.props.defaultEncrypted ?? privateShouldBeEncrypted(cli),
joinRule,
name: this.props.defaultName || "",
@@ -415,7 +422,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
labelKnock={
this.askToJoinEnabled ? _t("room_settings|security|join_rule_knock") : undefined
}
labelPublic={_t("common|public_room")}
labelPublic={this.allowCreatingPublicRooms ? _t("common|public_room") : undefined}
labelRestricted={
this.supportsRestricted ? _t("create_room|join_rule_restricted") : undefined
}
@@ -427,19 +434,21 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
{visibilitySection}
{e2eeSection}
{aliasField}
<details onToggle={this.onDetailsToggled} className="mx_CreateRoomDialog_details">
<summary className="mx_CreateRoomDialog_details_summary">
{this.state.detailsOpen ? _t("action|hide_advanced") : _t("action|show_advanced")}
</summary>
<LabelledToggleSwitch
label={_t("create_room|unfederated", {
serverName: MatrixClientPeg.safeGet().getDomain(),
})}
onChange={this.onNoFederateChange}
value={this.state.noFederate}
/>
<p>{federateLabel}</p>
</details>
{this.advancedSettingsEnabled && (
<details onToggle={this.onDetailsToggled} className="mx_CreateRoomDialog_details">
<summary className="mx_CreateRoomDialog_details_summary">
{this.state.detailsOpen ? _t("action|hide_advanced") : _t("action|show_advanced")}
</summary>
<LabelledToggleSwitch
label={_t("create_room|unfederated", {
serverName: MatrixClientPeg.safeGet().getDomain(),
})}
onChange={this.onNoFederateChange}
value={this.state.noFederate}
/>
<p>{federateLabel}</p>
</details>
)}
</div>
</form>
<DialogButtons

View File

@@ -84,7 +84,9 @@ const DevtoolsDialog: React.FC<IProps> = ({ roomId, threadRootId, onFinished })
<BaseTool onBack={onBack}>
{Object.entries(Tools).map(([category, tools]) => (
<div key={category}>
<h3>{_t(categoryLabels[category as unknown as Category])}</h3>
<h2 className="mx_DevTools_toolHeading">
{_t(categoryLabels[category as unknown as Category])}
</h2>
{tools.map(([label, tool]) => {
const onClick = (): void => {
setTool([label, tool]);
@@ -98,7 +100,7 @@ const DevtoolsDialog: React.FC<IProps> = ({ roomId, threadRootId, onFinished })
</div>
))}
<div>
<h3>{_t("common|options")}</h3>
<h2 className="mx_DevTools_toolHeading">{_t("common|options")}</h2>
<SettingsFlag name="developerMode" level={SettingLevel.ACCOUNT} />
<SettingsFlag name="showHiddenEventsInTimeline" level={SettingLevel.DEVICE} />
<SettingsFlag name="enableWidgetScreenshots" level={SettingLevel.ACCOUNT} />

View File

@@ -10,55 +10,19 @@ import React from "react";
import SetupEncryptionBody from "../../../structures/auth/SetupEncryptionBody";
import BaseDialog from "../BaseDialog";
import { _t } from "../../../../languageHandler";
import { SetupEncryptionStore, Phase } from "../../../../stores/SetupEncryptionStore";
function iconFromPhase(phase?: Phase): string {
if (phase === Phase.Done) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require("../../../../../res/img/e2e/verified-deprecated.svg").default;
} else {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require("../../../../../res/img/e2e/warning-deprecated.svg").default;
}
}
interface IProps {
onFinished(): void;
}
interface IState {
icon: string;
}
export default class SetupEncryptionDialog extends React.Component<IProps, IState> {
private store: SetupEncryptionStore;
export default class SetupEncryptionDialog extends React.Component<IProps> {
public constructor(props: IProps) {
super(props);
this.store = SetupEncryptionStore.sharedInstance();
this.state = { icon: iconFromPhase(this.store.phase) };
}
public componentDidMount(): void {
this.store.on("update", this.onStoreUpdate);
}
public componentWillUnmount(): void {
this.store.removeListener("update", this.onStoreUpdate);
}
private onStoreUpdate = (): void => {
this.setState({ icon: iconFromPhase(this.store.phase) });
};
public render(): React.ReactNode {
return (
<BaseDialog
headerImage={this.state.icon}
onFinished={this.props.onFinished}
title={_t("encryption|verify_toast_title")}
>
<BaseDialog onFinished={this.props.onFinished} fixedWidth={false}>
<SetupEncryptionBody onFinished={this.props.onFinished} />
</BaseDialog>
);

View File

@@ -572,7 +572,7 @@ export default class AppTile extends React.Component<IProps, IState> {
return (
<span>
<WidgetAvatar app={this.props.app} size="20px" />
<h3>{name}</h3>
<h1>{name}</h1>
<span>
{title ? titleSpacer : ""}
{title}

View File

@@ -19,7 +19,7 @@ interface IProps {
width?: number;
labelInvite: string;
labelKnock?: string;
labelPublic: string;
labelPublic?: string;
labelRestricted?: string; // if omitted then this option will be hidden, e.g if unsupported
onChange(value: JoinRule): void;
}
@@ -38,11 +38,18 @@ const JoinRuleDropdown: React.FC<IProps> = ({
<div key={JoinRule.Invite} className="mx_JoinRuleDropdown_invite">
{labelInvite}
</div>,
<div key={JoinRule.Public} className="mx_JoinRuleDropdown_public">
{labelPublic}
</div>,
] as NonEmptyArray<ReactElement & { key: string }>;
if (labelPublic) {
options.push(
(
<div key={JoinRule.Public} className="mx_JoinRuleDropdown_public">
{labelPublic}
</div>
) as ReactElement & { key: string },
);
}
if (labelKnock) {
options.unshift(
(
@@ -72,6 +79,7 @@ const JoinRuleDropdown: React.FC<IProps> = ({
menuWidth={width}
value={value}
label={label}
disabled={options.length === 1}
>
{options}
</Dropdown>

View File

@@ -48,4 +48,9 @@ export interface IBodyProps {
// Set to `true` to disable interactions (e.g. video controls) and to remove controls from the tab order.
// This may be useful when displaying a preview of the event.
inhibitInteraction?: boolean;
/**
* Optional ID for the root element.
*/
id?: string;
}

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 React from "react";
import React, { type JSX, useEffect, useMemo } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { type IContent } from "matrix-js-sdk/src/matrix";
import { type MediaEventContent } from "matrix-js-sdk/src/types";
@@ -17,8 +17,6 @@ import { _t } from "../../../languageHandler";
import MFileBody from "./MFileBody";
import { type IBodyProps } from "./IBodyProps";
import { PlaybackManager } from "../../../audio/PlaybackManager";
import { isVoiceMessage } from "../../../utils/EventUtils";
import { PlaybackQueue } from "../../../audio/PlaybackQueue";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import MediaProcessingError from "./shared/MediaProcessingError";
import { AudioPlayerViewModel } from "../../../viewmodels/audio/AudioPlayerViewModel";
@@ -27,7 +25,6 @@ import { AudioPlayerView } from "../../../shared-components/audio/AudioPlayerVie
interface IState {
error?: boolean;
playback?: Playback;
audioPlayerVm?: AudioPlayerViewModel;
}
export default class MAudioBody extends React.PureComponent<IBodyProps, IState> {
@@ -38,7 +35,6 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
public async componentDidMount(): Promise<void> {
let buffer: ArrayBuffer;
try {
try {
const blob = await this.props.mediaEventHelper!.sourceBlob.value;
@@ -63,18 +59,16 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
// We should have a buffer to work with now: let's set it up
const playback = PlaybackManager.instance.createPlaybackInstance(buffer, waveform);
playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent);
this.setState({ playback, audioPlayerVm: new AudioPlayerViewModel({ playback, mediaName: content.body }) });
this.setState({ playback });
if (isVoiceMessage(this.props.mxEvent)) {
PlaybackQueue.forRoom(this.props.mxEvent.getRoomId()!).unsortedEnqueue(this.props.mxEvent, playback);
}
// Note: the components later on will handle preparing the Playback class for us.
this.onMount(playback);
// Note: the components later on will handle preparing the Playback class for us
}
protected onMount(playback: Playback): void {}
public componentWillUnmount(): void {
this.state.playback?.destroy();
this.state.audioPlayerVm?.dispose();
}
protected get showFileBody(): boolean {
@@ -116,9 +110,35 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
// At this point we should have a playable state
return (
<span className="mx_MAudioBody">
{this.state.audioPlayerVm && <AudioPlayerView vm={this.state.audioPlayerVm} />}
<AudioPlayer playback={this.state.playback} mediaName={this.props.mxEvent.getContent().body} />
{this.showFileBody && <MFileBody {...this.props} showGenericPlaceholder={false} />}
</span>
);
}
}
interface AudioPlayerProps {
/**
* The playback instance to control audio playback.
*/
playback: Playback;
/**
* The name of the media being played
*/
mediaName: string;
}
/**
* AudioPlayer component that initializes the AudioPlayerViewModel and renders the AudioPlayerView.
*/
function AudioPlayer({ playback, mediaName }: AudioPlayerProps): JSX.Element {
const vm = useMemo(() => new AudioPlayerViewModel({ playback, mediaName }), [playback, mediaName]);
useEffect(() => {
return () => {
vm.dispose();
};
}, [vm]);
return <AudioPlayerView vm={vm} />;
}

View File

@@ -14,8 +14,17 @@ import RecordingPlayback from "../audio_messages/RecordingPlayback";
import MAudioBody from "./MAudioBody";
import MFileBody from "./MFileBody";
import MediaProcessingError from "./shared/MediaProcessingError";
import { isVoiceMessage } from "../../../utils/EventUtils";
import { PlaybackQueue } from "../../../audio/PlaybackQueue";
import { type Playback } from "../../../audio/Playback";
export default class MVoiceMessageBody extends MAudioBody {
protected onMount(playback: Playback): void {
if (isVoiceMessage(this.props.mxEvent)) {
PlaybackQueue.forRoom(this.props.mxEvent.getRoomId()!).unsortedEnqueue(this.props.mxEvent, playback);
}
}
// A voice message is an audio file but rendered in a special way.
public render(): React.ReactNode {
if (this.state.error) {

View File

@@ -51,6 +51,11 @@ interface IProps extends Omit<IBodyProps, "onMessageAllowed" | "mediaEventHelper
getRelationsForEvent?: GetRelationsForEvent;
isSeeingThroughMessageHiddenForModeration?: boolean;
/**
* Optional ID for the root element.
*/
id?: string;
}
export interface IOperableEventTile {
@@ -308,6 +313,7 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
getRelationsForEvent: this.props.getRelationsForEvent,
isSeeingThroughMessageHiddenForModeration: this.props.isSeeingThroughMessageHiddenForModeration,
inhibitInteraction: this.props.inhibitInteraction,
id: this.props.id,
};
if (hasCaption) {
return <CaptionBody {...bodyProps} WrappedBodyType={BodyType} />;

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