Compare commits

...

73 Commits

Author SHA1 Message Date
RiotRobot 4d55e8f433 v1.11.92
Dockerhub / Docker Buildx (push) Failing after 2m28s
2025-02-11 14:32:06 +00:00
RiotRobot 02990bd275 Upgrade dependency to matrix-js-sdk@36.2.0 2025-02-11 14:25:39 +00:00
Richard van der Hoff 67658aef56 Log when we show, and hide, encryption setup toasts (#29235) (#29238)
It's currently hard to debug when someone sees or hides one of these
toasts. Lets's add some logging.
2025-02-11 11:06:03 +00:00
ElementRobot 8941724020 [Backport staging] Wire up the "Forgot recovery key" button for the "Key storage out of sync" toast (#29190)
* Wire up the "Forgot recovery key" button for the "Key storage out of sync" toast (#29138)

* Wire up the "Forgot recovery key" button for the "Key storage out of sync" toast

* Unused import & fix test

* Test 'forgot' variant

* Fix dependencies

* Add more toast tests

* Unused import

* Test initialState in Encryption Tab

* Let's see if github has any more luck running this test than me

* Working playwright test with screenshot

* year

* Convert playwright test to use the bot client

* Disambiguate

Co-authored-by: Florian Duros <florianduros@element.io>

* Add doc & do other part of rename

* Split out into custom hook

* Fix tests

---------

Co-authored-by: Florian Duros <florianduros@element.io>
(cherry picked from commit 9657d39cd6)

* Update fetchdep.sh to understand merge queues

---------

Co-authored-by: David Baker <dbkr@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-02-07 00:01:52 +00:00
RiotRobot 0a8393c9e1 v1.11.92-rc.0
Dockerhub / Docker Buildx (push) Failing after 40s
2025-02-04 12:47:45 +00:00
RiotRobot 0fa52e610e Upgrade dependency to matrix-js-sdk@36.2.0-rc.0 2025-02-04 12:31:52 +00:00
R Midhun Suresh 1ea1d386ab Make profile header section match the designs (#29163)
* Update styling to match design

* Fix tests
2025-02-04 09:05:48 +00:00
ElementRobot afa6f377ea [create-pull-request] automated change (#29165)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-02-04 06:14:36 +00:00
Florian Duros b7f8623617 Encryption tab: hide Advanced section when the key storage is out of sync (#29129)
* fix(encryption tab): hide the advanced section when the secrets are not cached locally

The secret verification is now made at the level of `EncryptionUserSettingsTab` instead at the `RecoveryPanel` level. In the `EncryptionUserSettingsTab`, we decide to only display `RecoveryPanelOutOfSync` in case of uncached secrets.

`RecoveryPanelOutOfSync` is simplified version of `RecoveryPanel` handling only the `secrets_not_cached` case.

* refactor(encryption tab): simplify the `RecoveryPanel` without having to handle the missing secrets

* test(encryption tab): move test about cached secrets in `EncryptionUserSettingsTab-test.tsx`

* test(encryption tab): move e2e test which are testing all the encryption tab in `encryption-tab.spec.ts

* refactor(encryption tab): move `RecoveryPanelOutOfSync` in its own file

- fix typos
- call onFinish after accessSecretStorage
- onFinish doesn't need to be asynchronous

* doc(encryption tab): improve documentation when the secrets are not cached locally

* test(encryption tab): improve test documentation and naming

* doc(encryption tab): improve `RecoveryPanelOutOfSync` documentation
2025-02-03 13:47:55 +00:00
renovate[bot] e75ba356d3 Update dependency stylelint-config-standard to v37 (#29058)
* Update dependency stylelint-config-standard to v37

* Iterate

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-02-03 12:25:41 +00:00
Michael Telatynski 4f1eac67a8 Fix share button in discovery settings being disabled incorrectly (#29151)
* Fix share button in discovery settings being disabled incorrectly

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

* Improve types & add tests

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

* Iterate

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

* Iterate

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

* Improve coverage

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

* Add missing snapshot

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-02-03 08:48:02 +00:00
R Midhun Suresh aa01b17f9e Always show back button in the right panel (#29128)
* Construct history on setCard

So that back buttons are always shown in the right panel

* Check card state to ensure operation is atomic

* Fix tests

* Fix lint

* Remove null case

* Fix broken test
2025-02-02 18:37:12 +00:00
Hubert Chathi 4cba79ddcc Schedule dehydration on reload if the dehydration key is already cached locally (#29021)
* Schedule dehydration on reload

* fix test and use the right function to check dehydration is enabled

* use dehydration helper function when scheduling dehydration on restart

* fix test by passing in client object
2025-01-31 18:29:59 +00:00
Michael Telatynski b64471e4f6 Ensure switching rooms does not wrongly focus timeline search (#29153)
This happened due to the focusRoomSearch param being stored for inactive rooms so it never got cleared

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-01-31 15:47:32 +00:00
Valere d3a6f34881 feat(crypto): Support verification violation composer banner (#29067)
* feat(crypto): Support verification violation composer banner

* refactor UserIdentityWarning by using now a ViewModel

fixup: logger import

fixup: test lint type problems

fix test having an unexpected verification violation

fixup sonarcubes warnings

* review: comments on types and inline some const

* review: Quick refactor, better handling of action on button click

* review: Small updates, remove commented code
2025-01-31 15:05:32 +00:00
ElementRobot dcce9c70dc Localazy Download (#29149)
* [create-pull-request] automated change

* Fix test

---------

Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
Co-authored-by: R Midhun Suresh <hi@midhun.dev>
2025-01-31 12:30:53 +00:00
ElementRobot f06ed2fa1f [create-pull-request] automated change (#29148)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-01-31 06:14:35 +00:00
Richard van der Hoff 099c3073b6 Stop showing a dialog prompting the user to enter an old recovery key (#29143)
* SecurityManager: improve logging

* Only prompt user for default 4S key

We don't really support the concept of having multiple 4S keys active, so
prompting the user to enter a non-default 4S key without even telling them
which one we want is rather silly.

* playwright: factor out helper for setting up 4S

We seem to already have about 5 copies of this code, so before I add another,
let's factor it out.

* Playwright test for dehydrated device in reset flow

This should be fixed by the previous commit, so let's check it stays that way.
2025-01-30 16:27:45 +00:00
ElementRobot 12932e2dc6 [create-pull-request] automated change (#29144)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-01-30 09:22:24 +00:00
David Langley a7de29429c Bump emojibase_bindings to include emoji 15.1 (#29132) 2025-01-29 17:42:22 +00:00
Florian Duros d3ea250d77 Remove call to MatrixClient.setGlobalErrorOnUnknownDevices (#29134)
* refactor(MatrixChat): remove `MatrixClient.setGlobalErrorOnUnknownDevices` call

MatrixClient.setGlobalErrorOnUnknownDevices is not implemented in the rust-crypto and will be removed when the legacy crypto will be ripped out.

* test(e2e): remove `MatrixClient.setGlobalErrorOnUnknownDevices` call

MatrixClient.setGlobalErrorOnUnknownDevices is not implemented in the rust-crypto and will be removed when the legacy crypto will be ripped out.
2025-01-29 17:24:44 +00:00
ElementRobot f243fee5a6 [create-pull-request] automated change (#29122)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-01-29 06:18:59 +00:00
ElementRobot 296d0074ed [create-pull-request] automated change (#29121)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-01-29 06:14:16 +00:00
RiotRobot df83338f26 Reset matrix-js-sdk back to develop branch 2025-01-28 13:36:28 +00:00
RiotRobot c0336f21f6 Merge branch 'master' into develop 2025-01-28 13:36:12 +00:00
Matthew Hodgson 886d0e1241 update to twemoji 15.1.0 (#29115) 2025-01-28 11:15:33 +00:00
Robin c453d33456 Make themed widgets reflect the effective theme (#28342)
* Make themed widgets reflect the effective theme

So that widgets such as Element Call will show up in the right theme even if the app is set to match the system theme.

* Remove debug log line
2025-01-28 10:53:35 +00:00
ElementRobot ddf221b813 [create-pull-request] automated change (#29114)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-01-28 06:14:17 +00:00
Timo 08238bb883 Update matrix-widget-api (#29112)
This fixes element call not loading with the `update_state` refactor: https://github.com/element-hq/element-web/pull/28681
2025-01-27 17:46:39 +00:00
Michael Telatynski c390ec333e Tidy up modules (#29089)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-01-27 17:27:27 +00:00
Michael Telatynski 3c22e5dc68 Switch to mailpit for Playwright tests (#29108)
* Switch to mailpit for Playwright tests

as mailhog is unsupported and lacks arm64 support

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

* Fix yarn.lock

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

* Iterate

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

* Iterate

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

* delint

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

* Iterate

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-01-27 15:35:06 +00:00
R Midhun Suresh f29ce94dd4 Style room header icons and facepile for toggled state (#28968)
* Fix tiny typo in existing code

* Create a hook that uses the right panel store

So that we track changes to the right panel phases

* Create a context that wraps the previous hook we created

We do this so that we can get by using a single event listener i.e we
only need to call `useCurrentPhase` in the provider as opposed to
calling it in each header icon.

* Create a hook that tells you if a panel is open or not

* Create component that wraps Icon

and adds a class name when the corresponding panel is open

* Style room header icons for when they are toggled

* Style face pile for toggle state

* Fix broken CI

* Give directory a better name

* Update year in license

* Use a stronger type
2025-01-27 15:05:46 +00:00
R Midhun Suresh 76485cfb17 Allow navigating through the memberlist using up/down keys (#28949)
* Allow flex component to take child containers props

So that we can set attributes on the container

* Use Up/Down arrow keys to navigate through the list

* Update snapshot
2025-01-27 15:05:28 +00:00
Matthew Hodgson c0567fc5f4 support non-VS16 emoji ligatures in TwemojiMozilla (#29100)
to workaround broken ligature support for VS16 ligature colour emoji in Chrome 131+
See https://github.com/element-hq/element-web/issues/28500
2025-01-27 14:54:13 +00:00
R Midhun Suresh 95da3834f2 Move threads header below base card header (#28969)
* Move threads header below base card header

* Fix jest tests
2025-01-27 11:10:25 +00:00
ElementRobot b8a3468485 [create-pull-request] automated change (#29025)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-01-27 08:51:33 +00:00
ElementRobot b7f6e0f88c [create-pull-request] automated change (#29092)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-01-25 06:13:48 +00:00
Florian Duros b36d9ce32e test(e2e): verify session with the encryption tab instead of the security & privacy tab (#29090) 2025-01-24 15:03:59 +00:00
Michael Telatynski 9b6be0f5a9 Tidy up unused classes & icons in UserMenu styles (#29087)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-01-24 14:52:24 +00:00
David Baker cae0da8f00 Fix prettier 2025-01-24 14:23:39 +00:00
David Baker 25689de34a Attempt 3 at working around R2 brokenness 2025-01-24 14:07:55 +00:00
Michael Telatynski ce6cb47943 Attempt 2 at working around aws-cli & r2 incompatibility 2025-01-24 13:46:02 +00:00
Florian Duros 850c1a5b3a e2e test: Check key backup with js-sdk api instead of relying of Security & Privacy tab (#29066)
* test(e2e): `checkDeviceIsConnectedKeyBackup` is checking the key backup with the matrix client and the crypto api instead of relying of the `Security & Privacy` tab.

* test(e2e): renaming and improve documentation
2025-01-24 12:34:22 +00:00
David Baker ec4ae9e58a Work around cloudflare R2 / aws client incompatability (#29086)
Hopefully...

https://www.cloudflarestatus.com/incidents/t5nrjmpxc1cj
2025-01-24 12:20:22 +00:00
Florian Duros a73eb378d7 Add cryptography information in devtools (#29073)
* feat(devtools): add crypto information in devtools

* ci: add crypto devtools file to crypto code owners

* test(dev tools): update test to add new crypto button

* test(dev tools): add tests for crypto component
2025-01-24 10:51:27 +00:00
David Baker 197afd6a9e Fix identity server settings visibility (#29083)
* Fix identity server settings visibility

The IS settings got confused with the posthog settings and were only
shown if analytics were enabled.

* Update snapshot
2025-01-24 10:07:26 +00:00
Florian Duros ac565dca80 Add Advanced section to the user settings encryption tab (#28804)
* Make the encryption card more configurable:
- Change the icon
- Can set the destructive props

* Update compound

* Add advanced section

* Add the `Never send encrypted messages to unverified devices` settings

* - Add commercial license
- Remove generic type

* Rename EncryptionDetails css classes

* Use same uiAuthCallback

* Use h3 for title

* Add tests to `AdvancedPanel`

* Add tests to `EncryptionUserSettingsTab`

* Add tests to `ResetIdentityPanel`

* Get only the recovery section in recovery tests

* Add e2e test
2025-01-24 08:33:16 +00:00
renovate[bot] a0044d6b5f Update testcontainers-node monorepo to v10.17.1 (#29053)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-01-23 00:03:47 +00:00
taffyko 68c03db557 Fix outstanding UX issues with replies/mentions/keyword notifs (#28270)
* Fix outstanding UX issues with replies/mentions/keyword notifs

* Use createRoot instead of deprecated ReactDOM.render

I foresee this change being made across the codebase shortly
and want to proactively prevent my PR from falling behind

* Clean up react root on unmount

* Remove addition of left-edge highlight on message mentions

It is clear that it would be best for me to address
this piece in a separate PR.

* Update call to ReactRootManager.render

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-01-22 21:42:40 +00:00
Florian Duros 9a109cdce8 test: remove unused toast screenshot (#29074) 2025-01-22 18:47:49 +00:00
Robin a0ab88943b Distinguish room state and timeline events when dealing with widgets (#28681)
* Distinguish room state and timeline events when dealing with widgets

* Upgrade matrix-widget-api

* Fix typo

* Fix tests

* Write more tests

* Add more comments

---------

Co-authored-by: Hugh Nimmo-Smith <hughns@users.noreply.github.com>
2025-01-22 17:50:52 +00:00
Michael Telatynski ad01218942 Switch OIDC primarily to new /auth_metadata API (#29019)
* Switch OIDC primarily to new `/auth_metadata` API

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

* Update tests

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

* Iterate

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

* Simplify the world

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

* Iterate

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-01-22 13:48:28 +00:00
R Midhun Suresh e1e4d26154 More memberlist changes (#29069)
* Remove parenthesis from Invited user label

* Ensure adequate margin

* Truncate user id with ellipsis

* Fix tests
2025-01-22 13:31:47 +00:00
Michael Telatynski 84c614676d Improve release documentation for staging.element.io deployments (#29038)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-01-22 09:11:35 +00:00
ElementRobot 29d9e98111 [create-pull-request] automated change (#29063)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-01-22 06:14:32 +00:00
renovate[bot] 9f5f898ed8 Update all non-major dependencies (#29046)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-21 19:41:52 +00:00
renovate[bot] 78251a3a8a Update dependency @types/node to v18.19.71 (#29042)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-21 19:16:57 +00:00
renovate[bot] 1b077c53f5 Update dependency @sentry/browser to v8.50.0 (#29048)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-21 18:48:22 +00:00
renovate[bot] 68828a2326 Update stylelint (#29052)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-21 18:33:50 +00:00
renovate[bot] af8d93f58a Update typescript-eslint monorepo to v8.20.0 (#29055)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-21 18:27:19 +00:00
renovate[bot] c0a097867e Update babel monorepo (#29047)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-21 18:11:26 +00:00
renovate[bot] 0b13e57518 Update docker/build-push-action digest to 67a2d40 (#29039)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-21 18:05:56 +00:00
renovate[bot] 8615b411b2 Update browserslist (#29041)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-21 18:05:18 +00:00
renovate[bot] 3d31376b1d Update guibranco/github-status-action-v2 digest to ecd54a0 (#29040)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-21 17:50:02 +00:00
renovate[bot] 43e5124cd4 Update matrix-org (#29051)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-21 17:42:33 +00:00
renovate[bot] 19674cca08 Update dependency @sentry/webpack-plugin to v3 (#29056)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-21 17:39:34 +00:00
renovate[bot] 6ca6cb0fbe Update testing-library monorepo (#29054)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-21 17:37:10 +00:00
renovate[bot] d92fc5a595 Update dependency @types/react-transition-group to v4.4.12 (#29043)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-21 17:36:43 +00:00
renovate[bot] b9d411eecc Update dependency typescript to v5.7.3 (#29044)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-21 17:36:26 +00:00
renovate[bot] 3da6619bcf Update dependency @stylistic/eslint-plugin to v2.13.0 (#29049)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-21 17:34:38 +00:00
renovate[bot] f33e7c9782 Update fontsource monorepo to v5.1.1 (#29045)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-21 17:34:23 +00:00
renovate[bot] 1ebae09834 Update dependency eslint-config-prettier to v10 (#29057)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-21 17:33:17 +00:00
David Baker 56eafc908e Switch to secure random strings (#29013)
* Switch to secure random strings

Because the js-sdk methods are changing and there's no reason for these
not to use the secure versions. The dedicated upper/lower functions were
*only* used in this one case, so this should do the exact same thing with
the one exported function.

Requires https://github.com/matrix-org/matrix-js-sdk/pull/4621 (merge both together)

* Change remaining instances of randomString

which I somehow entirely missed the first time.

* Fix import order
2025-01-21 13:54:57 +00:00
182 changed files with 7022 additions and 2140 deletions
+1
View File
@@ -7,3 +7,4 @@ test/end-to-end-tests/lib/
src/component-index.js
# Auto-generated file
src/modules.ts
src/modules.js
+3 -2
View File
@@ -10,10 +10,11 @@
/test/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/src/stores/SetupEncryptionStore.ts @element-hq/element-crypto-web-reviewers
/test/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers
/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @element-hq/element-crypto-web-reviewers
/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @element-hq/element-crypto-web-reviewers
/src/src/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
/test/unit-tests/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
/playwright/e2e/settings/encryption-user-tab/ @element-hq/element-crypto-web-reviewers
/playwright/e2e/settings/encryption-user-tab/ @element-hq/element-crypto-web-reviewers
/src/components/views/dialogs/devtools/Crypto.tsx @element-hq/element-crypto-web-reviewers
# Ignore translations as those will be updated by GHA for Localazy download
/src/i18n/strings
+6
View File
@@ -26,6 +26,12 @@ jobs:
R2_URL: ${{ vars.CF_R2_S3_API }}
R2_PUBLIC_URL: "https://element-web-develop.element.io"
steps:
# Workaround for https://www.cloudflarestatus.com/incidents/t5nrjmpxc1cj
- uses: unfor19/install-aws-cli-action@v1
with:
version: 2.22.35
verbose: false
arch: amd64
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
- name: Build and push
id: build-and-push
uses: docker/build-push-action@b32b51a8eda65d6793cd0494a773d4f6bcef32dc # v6
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6
with:
context: .
push: true
+1 -1
View File
@@ -104,7 +104,7 @@ jobs:
- name: Skip SonarCloud in merge queue
if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true'
uses: guibranco/github-status-action-v2@56cd38caf0615dd03f49d42ed301f1469911ac61
uses: guibranco/github-status-action-v2@ecd54a02cf761e85a8fb328fe937710fd4227cda
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
+1
View File
@@ -26,6 +26,7 @@ electron/pub
/coverage
# Auto-generated file
/src/modules.ts
/src/modules.js
/build_config.yaml
/book
/index.html
+1
View File
@@ -17,6 +17,7 @@ electron/pub
/coverage
# Auto-generated file
/src/modules.ts
/src/modules.js
/src/i18n/strings
/build_config.yaml
# Raises an error because it contains a template var breaking the script tag
+2 -6
View File
@@ -33,19 +33,15 @@ module.exports = {
"import-notation": null,
"value-keyword-case": null,
"declaration-block-no-redundant-longhand-properties": null,
"declaration-block-no-duplicate-properties": [
true,
// useful for fallbacks
{ ignore: ["consecutive-duplicates-with-different-values"] },
],
"shorthand-property-no-redundant-values": null,
"property-no-vendor-prefix": null,
"value-no-vendor-prefix": null,
"selector-no-vendor-prefix": null,
"media-feature-name-no-vendor-prefix": null,
"number-max-precision": null,
"no-invalid-double-slash-comments": true,
"media-feature-range-notation": null,
"declaration-property-value-no-unknown": null,
"declaration-property-value-keyword-no-deprecated": null,
"csstools/value-no-unknown-custom-properties": [
true,
{
+33
View File
@@ -1,3 +1,36 @@
Changes in [1.11.92](https://github.com/element-hq/element-web/releases/tag/v1.11.92) (2025-02-11)
==================================================================================================
## ✨ Features
* [Backport staging] Log when we show, and hide, encryption setup toasts ([#29238](https://github.com/element-hq/element-web/pull/29238)). Contributed by @richvdh.
* Make profile header section match the designs ([#29163](https://github.com/element-hq/element-web/pull/29163)). Contributed by @MidhunSureshR.
* Always show back button in the right panel ([#29128](https://github.com/element-hq/element-web/pull/29128)). Contributed by @MidhunSureshR.
* Schedule dehydration on reload if the dehydration key is already cached locally ([#29021](https://github.com/element-hq/element-web/pull/29021)). Contributed by @uhoreg.
* update to twemoji 15.1.0 ([#29115](https://github.com/element-hq/element-web/pull/29115)). Contributed by @ara4n.
* Update matrix-widget-api ([#29112](https://github.com/element-hq/element-web/pull/29112)). Contributed by @toger5.
* Allow navigating through the memberlist using up/down keys ([#28949](https://github.com/element-hq/element-web/pull/28949)). Contributed by @MidhunSureshR.
* Style room header icons and facepile for toggled state ([#28968](https://github.com/element-hq/element-web/pull/28968)). Contributed by @MidhunSureshR.
* Move threads header below base card header ([#28969](https://github.com/element-hq/element-web/pull/28969)). Contributed by @MidhunSureshR.
* Add `Advanced` section to the user settings encryption tab ([#28804](https://github.com/element-hq/element-web/pull/28804)). Contributed by @florianduros.
* Fix outstanding UX issues with replies/mentions/keyword notifs ([#28270](https://github.com/element-hq/element-web/pull/28270)). Contributed by @taffyko.
* Distinguish room state and timeline events when dealing with widgets ([#28681](https://github.com/element-hq/element-web/pull/28681)). Contributed by @robintown.
* Switch OIDC primarily to new `/auth_metadata` API ([#29019](https://github.com/element-hq/element-web/pull/29019)). Contributed by @t3chguy.
* More memberlist changes ([#29069](https://github.com/element-hq/element-web/pull/29069)). Contributed by @MidhunSureshR.
## 🐛 Bug Fixes
* [Backport staging] Wire up the "Forgot recovery key" button for the "Key storage out of sync" toast ([#29190](https://github.com/element-hq/element-web/pull/29190)). Contributed by @RiotRobot.
* Encryption tab: hide `Advanced` section when the key storage is out of sync ([#29129](https://github.com/element-hq/element-web/pull/29129)). Contributed by @florianduros.
* Fix share button in discovery settings being disabled incorrectly ([#29151](https://github.com/element-hq/element-web/pull/29151)). Contributed by @t3chguy.
* Ensure switching rooms does not wrongly focus timeline search ([#29153](https://github.com/element-hq/element-web/pull/29153)). Contributed by @t3chguy.
* Stop showing a dialog prompting the user to enter an old recovery key ([#29143](https://github.com/element-hq/element-web/pull/29143)). Contributed by @richvdh.
* Make themed widgets reflect the effective theme ([#28342](https://github.com/element-hq/element-web/pull/28342)). Contributed by @robintown.
* support non-VS16 emoji ligatures in TwemojiMozilla ([#29100](https://github.com/element-hq/element-web/pull/29100)). Contributed by @ara4n.
* e2e test: Verify session with the encryption tab instead of the security \& privacy tab ([#29090](https://github.com/element-hq/element-web/pull/29090)). Contributed by @florianduros.
* Work around cloudflare R2 / aws client incompatability ([#29086](https://github.com/element-hq/element-web/pull/29086)). Contributed by @dbkr.
* Fix identity server settings visibility ([#29083](https://github.com/element-hq/element-web/pull/29083)). Contributed by @dbkr.
Changes in [1.11.91](https://github.com/element-hq/element-web/releases/tag/v1.11.91) (2025-01-28)
==================================================================================================
## ✨ Features
+7 -2
View File
@@ -8,11 +8,13 @@
#### develop
The develop branch holds the very latest and greatest code we have to offer, as such it may be less stable. It corresponds to the develop.element.io CD platform.
The develop branch holds the very latest and greatest code we have to offer, as such it may be less stable.
It is auto-deployed on every commit to element-web or matrix-js-sdk to develop.element.io via GitHub Actions `build_develop.yml`.
#### staging
The staging branch corresponds to the very latest release regardless of whether it is an RC or not. Deployed to staging.element.io manually.
It is auto-deployed on every release of element-web to staging.element.io via GitHub Actions `deploy.yml`.
#### master
@@ -215,7 +217,7 @@ We ship Element Web to dockerhub, `*.element.io`, and packages.element.io.
We ship Element Desktop to packages.element.io.
- [ ] Check that element-web has shipped to dockerhub
- [ ] Deploy staging.element.io. [See docs.](https://handbook.element.io/books/element-web-team/page/deploying-appstagingelementio)
- [ ] Check that the staging [deployment](https://github.com/element-hq/element-web/actions/workflows/deploy.yml) has completed successfully
- [ ] Test staging.element.io
For final releases additionally do these steps:
@@ -225,6 +227,9 @@ For final releases additionally do these steps:
- [ ] Ensure Element Web package has shipped to packages.element.io
- [ ] Ensure Element Desktop packages have shipped to packages.element.io
If you need to roll back a deployment to staging.element.io,
you can run the `deploy.yml` automation choosing an older tag which you wish to deploy.
# Housekeeping
We have some manual housekeeping to do in order to prepare for the next release.
+4 -5
View File
@@ -23,10 +23,9 @@ const MODULES_TS_HEADER = `
* You are not a salmon.
*/
import { RuntimeModule } from "@matrix-org/react-sdk-module-api/lib/RuntimeModule";
`;
const MODULES_TS_DEFINITIONS = `
export const INSTALLED_MODULES: RuntimeModule[] = [];
export const INSTALLED_MODULES = [];
`;
export function installer(config: BuildConfig): void {
@@ -78,8 +77,8 @@ export function installer(config: BuildConfig): void {
return; // hit the finally{} block before exiting
}
// If we reach here, everything seems fine. Write modules.ts and log some output
// Note: we compile modules.ts in two parts for developer friendliness if they
// If we reach here, everything seems fine. Write modules.js and log some output
// Note: we compile modules.js in two parts for developer friendliness if they
// happen to look at it.
console.log("The following modules have been installed: ", installedModules);
let modulesTsHeader = MODULES_TS_HEADER;
@@ -193,5 +192,5 @@ function isModuleVersionCompatible(ourApiVersion: string, moduleApiVersion: stri
}
function writeModulesTs(content: string): void {
fs.writeFileSync("./src/modules.ts", content, "utf-8");
fs.writeFileSync("./src/modules.js", content, "utf-8");
}
+11 -11
View File
@@ -1,6 +1,6 @@
{
"name": "element-web",
"version": "1.11.91",
"version": "1.11.92",
"description": "A feature-rich client for Matrix.org",
"author": "New Vector Ltd.",
"repository": {
@@ -74,7 +74,7 @@
"@types/react-dom": "18.3.5",
"oidc-client-ts": "3.1.0",
"jwt-decode": "4.0.0",
"caniuse-lite": "1.0.30001690",
"caniuse-lite": "1.0.30001692",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
"wrap-ansi": "npm:wrap-ansi@^7.0.0"
},
@@ -84,14 +84,14 @@
"@fontsource/inter": "^5",
"@formatjs/intl-segmenter": "^11.5.7",
"@matrix-org/analytics-events": "^0.29.0",
"@matrix-org/emojibase-bindings": "^1.3.3",
"@matrix-org/emojibase-bindings": "^1.3.4",
"@matrix-org/react-sdk-module-api": "^2.4.0",
"@matrix-org/spec": "^1.7.0",
"@sentry/browser": "^8.0.0",
"@types/png-chunks-extract": "^1.0.2",
"@types/react-virtualized": "^9.21.30",
"@vector-im/compound-design-tokens": "^2.1.0",
"@vector-im/compound-web": "^7.5.0",
"@vector-im/compound-web": "^7.6.1",
"@vector-im/matrix-wysiwyg": "2.38.0",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
@@ -127,7 +127,7 @@
"maplibre-gl": "^5.0.0",
"matrix-encrypt-attachment": "^1.0.3",
"matrix-events-sdk": "0.0.1",
"matrix-js-sdk": "36.1.0",
"matrix-js-sdk": "36.2.0",
"matrix-widget-api": "^1.10.0",
"memoize-one": "^6.0.0",
"mime": "^4.0.4",
@@ -178,7 +178,7 @@
"@peculiar/webcrypto": "^1.4.3",
"@playwright/test": "^1.40.1",
"@principalstudio/html-webpack-inject-preload": "^1.2.7",
"@sentry/webpack-plugin": "^2.7.1",
"@sentry/webpack-plugin": "^3.0.0",
"@stylistic/eslint-plugin": "^2.9.0",
"@svgr/webpack": "^8.0.0",
"@testcontainers/postgresql": "^10.16.0",
@@ -230,7 +230,7 @@
"dotenv": "^16.0.2",
"eslint": "8.57.1",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^9.0.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-deprecate": "0.8.5",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jest": "^28.0.0",
@@ -256,7 +256,7 @@
"jsqr": "^1.4.0",
"knip": "^5.36.2",
"lint-staged": "^15.0.2",
"mailhog": "^4.16.0",
"mailpit-api": "^1.0.5",
"matrix-web-i18n": "^3.2.1",
"mini-css-extract-plugin": "2.9.2",
"minimist": "^1.2.6",
@@ -280,14 +280,14 @@
"semver": "^7.5.2",
"source-map-loader": "^5.0.0",
"strip-ansi": "^7.1.0",
"stylelint": "^16.1.0",
"stylelint-config-standard": "^36.0.0",
"stylelint": "^16.13.0",
"stylelint-config-standard": "^37.0.0",
"stylelint-scss": "^6.0.0",
"stylelint-value-no-unknown-custom-properties": "^6.0.1",
"terser-webpack-plugin": "^5.3.9",
"testcontainers": "^10.16.0",
"ts-node": "^10.9.1",
"typescript": "5.7.2",
"typescript": "5.7.3",
"util": "^0.12.5",
"web-streams-polyfill": "^4.0.0",
"webpack": "^5.89.0",
+6 -6
View File
@@ -19,19 +19,19 @@ test.use(masHomeserver);
test.describe("Encryption state after registration", () => {
test.skip(isDendrite, "does not yet support MAS");
test("Key backup is enabled by default", async ({ page, mailhogClient, app }, testInfo) => {
test("Key backup is enabled by default", async ({ page, mailpitClient, app }, testInfo) => {
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailhogClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!");
await registerAccountMas(page, mailpitClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!");
await app.settings.openUserSettings("Security & Privacy");
await expect(page.getByText("This session is backing up your keys.")).toBeVisible();
});
test("user is prompted to set up recovery", async ({ page, mailhogClient, app }, testInfo) => {
test("user is prompted to set up recovery", async ({ page, mailpitClient, app }, testInfo) => {
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailhogClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!");
await registerAccountMas(page, mailpitClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!");
await page.getByRole("button", { name: "Add room" }).click();
await page.getByRole("menuitem", { name: "New room" }).click();
@@ -47,7 +47,7 @@ test.describe("Key backup reset from elsewhere", () => {
test("Key backup is disabled when reset from elsewhere", async ({
page,
mailhogClient,
mailpitClient,
request,
homeserver,
}, testInfo) => {
@@ -60,7 +60,7 @@ test.describe("Key backup reset from elsewhere", () => {
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailhogClient, testUsername, "alice@email.com", testPassword);
await registerAccountMas(page, mailpitClient, testUsername, "alice@email.com", testPassword);
await page.getByRole("button", { name: "Add room" }).click();
await page.getByRole("menuitem", { name: "New room" }).click();
+3 -13
View File
@@ -10,6 +10,7 @@ import { type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { completeCreateSecretStorageDialog } from "./utils.ts";
async function expectBackupVersionToBe(page: Page, version: string) {
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText(
@@ -35,19 +36,7 @@ test.describe("Backups", () => {
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
const currentDialogLocator = page.locator(".mx_Dialog");
// It's the first time and secure storage is not set up, so it will create one
await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click();
// copy the recovery key to use it later
const securityKey = await app.getClipboard();
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Done", exact: true }).click();
const securityKey = await completeCreateSecretStorageDialog(page);
// Open the settings again
await app.settings.openUserSettings("Security & Privacy");
@@ -62,6 +51,7 @@ test.describe("Backups", () => {
await expectBackupVersionToBe(page, "1");
await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click();
const currentDialogLocator = page.locator(".mx_Dialog");
await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible();
// Delete it
await currentDialogLocator.getByTestId("dialog-primary-button").click(); // Click "Delete Backup"
+9 -13
View File
@@ -8,7 +8,14 @@ Please see LICENSE files in the repository root for full details.
import type { Page } from "@playwright/test";
import { expect, test } from "../../element-web-test";
import { autoJoin, copyAndContinue, createSharedRoomWithUser, enableKeyBackup, verify } from "./utils";
import {
autoJoin,
completeCreateSecretStorageDialog,
copyAndContinue,
createSharedRoomWithUser,
enableKeyBackup,
verify,
} from "./utils";
import { Bot } from "../../pages/bot";
import { ElementAppPage } from "../../pages/ElementAppPage";
import { isDendrite } from "../../plugins/homeserver/dendrite";
@@ -111,18 +118,7 @@ test.describe("Cryptography", function () {
await app.settings.openUserSettings("Security & Privacy");
await page.getByRole("button", { name: "Set up Secure Backup" }).click();
const dialog = page.locator(".mx_Dialog");
// Recovery key is selected by default
await dialog.getByRole("button", { name: "Continue" }).click();
await copyAndContinue(page);
// If the device is unverified, there should be a "Setting up keys" step; however, it
// can be quite quick, and playwright can miss it, so we can't test for it.
// Either way, we end up at a success dialog:
await expect(dialog.getByText("Secure Backup successful")).toBeVisible();
await dialog.getByRole("button", { name: "Done" }).click();
await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible();
await completeCreateSecretStorageDialog(page);
// Verify that the SSSS keys are in the account data stored in the server
await verifyKey(app, "master");
+49 -12
View File
@@ -11,6 +11,8 @@ import { Locator, type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { viewRoomSummaryByName } from "../right-panel/utils";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { completeCreateSecretStorageDialog, createBot, logIntoElement } from "./utils.ts";
import { Client } from "../../pages/client.ts";
const ROOM_NAME = "Test room";
const NAME = "Alice";
@@ -44,7 +46,7 @@ test.use({
test.describe("Dehydration", () => {
test.skip(isDendrite, "does not yet support dehydration v2");
test("Create dehydrated device", async ({ page, user, app }, workerInfo) => {
test("'Set up secure backup' creates dehydrated device", async ({ page, user, app }, workerInfo) => {
// Create a backup (which will create SSSS, and dehydrated device)
const securityTab = await app.settings.openUserSettings("Security & Privacy");
@@ -53,17 +55,7 @@ test.describe("Dehydration", () => {
await expect(securityTab.getByText("Offline device enabled")).not.toBeVisible();
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
const currentDialogLocator = page.locator(".mx_Dialog");
// It's the first time and secure storage is not set up, so it will create one
await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click();
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Done", exact: true }).click();
await completeCreateSecretStorageDialog(page);
// Open the settings again
await app.settings.openUserSettings("Security & Privacy");
@@ -96,4 +88,49 @@ test.describe("Dehydration", () => {
await expect(page.locator(".mx_UserInfo_devices").getByText("Offline device enabled")).toBeVisible();
await expect(page.locator(".mx_UserInfo_devices").getByText("Dehydrated device")).not.toBeVisible();
});
test("Reset recovery key during login re-creates dehydrated device", async ({
page,
homeserver,
app,
credentials,
}) => {
// Set up cross-signing and recovery
const { botClient } = await createBot(page, homeserver, credentials);
// ... and dehydration
await botClient.evaluate(async (client) => await client.getCrypto().startDehydration());
const initialDehydratedDeviceIds = await getDehydratedDeviceIds(botClient);
expect(initialDehydratedDeviceIds.length).toBe(1);
await botClient.evaluate(async (client) => client.stopClient());
// Log in our client
await logIntoElement(page, credentials);
// Oh no, we forgot our recovery key
await page.locator(".mx_AuthPage").getByRole("button", { name: "Reset all" }).click();
await page.locator(".mx_AuthPage").getByRole("button", { name: "Proceed with reset" }).click();
await completeCreateSecretStorageDialog(page, { accountPassword: credentials.password });
// There should be a brand new dehydrated device
const dehydratedDeviceIds = await getDehydratedDeviceIds(app.client);
expect(dehydratedDeviceIds.length).toBe(1);
expect(dehydratedDeviceIds[0]).not.toEqual(initialDehydratedDeviceIds[0]);
});
});
async function getDehydratedDeviceIds(client: Client): Promise<string[]> {
return await client.evaluate(async (client) => {
const userId = client.getUserId();
const devices = await client.getCrypto().getUserDeviceInfo([userId]);
return Array.from(
devices
.get(userId)
.values()
.filter((d) => d.dehydrated)
.map((d) => d.deviceId),
);
});
}
@@ -68,8 +68,8 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
// Check that the current device is connected to key backup
// For now we don't check that the backup key is in cache because it's a bit flaky,
// as we need to wait for the secret gossiping to happen and the settings dialog doesn't refresh automatically.
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, false);
// as we need to wait for the secret gossiping to happen.
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, false);
});
test("Verify device with QR code during login", async ({ page, app, credentials, homeserver }) => {
@@ -112,9 +112,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
await checkDeviceIsCrossSigned(app);
// Check that the current device is connected to key backup
// For now we don't check that the backup key is in cache because it's a bit flaky,
// as we need to wait for the secret gossiping to happen and the settings dialog doesn't refresh automatically.
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, false);
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
});
test("Verify device with Security Phrase during login", async ({ page, app, credentials, homeserver }) => {
@@ -135,7 +133,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
// Check that the current device is connected to key backup
// The backup decryption key should be in cache also, as we got it directly from the 4S
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
});
test("Verify device with Security Key during login", async ({ page, app, credentials, homeserver }) => {
@@ -158,7 +156,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
// Check that the current device is connected to key backup
// The backup decryption key should be in cache also, as we got it directly from the 4S
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
});
test("Handle incoming verification request with SAS", async ({ page, credentials, homeserver, toasts }) => {
+54
View File
@@ -0,0 +1,54 @@
/*
* 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 { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
import { test, expect } from "../../element-web-test";
import { createBot, deleteCachedSecrets, logIntoElement } from "./utils";
test.describe("Key storage out of sync toast", () => {
let recoveryKey: GeneratedSecretStorageKey;
test.beforeEach(async ({ page, homeserver, credentials }) => {
const res = await createBot(page, homeserver, credentials);
recoveryKey = res.recoveryKey;
await logIntoElement(page, credentials, recoveryKey.encodedPrivateKey);
await deleteCachedSecrets(page);
// 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();
await page.getByRole("menuitem", { name: "New room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("Test room");
await page.getByRole("button", { name: "Create room" }).click();
});
test("should prompt for recovery key if 'enter recovery key' pressed", { tag: "@screenshot" }, async ({ page }) => {
// Need to wait for 2 to appear since playwright only evaluates 'first()' initially, so the waiting won't work
await expect(page.getByRole("alert")).toHaveCount(2);
await expect(page.getByRole("alert").first()).toMatchScreenshot("key-storage-out-of-sync-toast.png");
await page.getByRole("button", { name: "Enter recovery key" }).click();
await page.locator(".mx_Dialog").getByRole("button", { name: "use your Security Key" }).click();
await page.getByRole("textbox", { name: "Security key" }).fill(recoveryKey.encodedPrivateKey);
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.getByRole("button", { name: "Enter recovery key" })).not.toBeVisible();
});
test("should open settings to reset flow if 'forgot recovery key' pressed", async ({ page, app, credentials }) => {
await expect(page.getByRole("button", { name: "Enter recovery key" })).toBeVisible();
await page.getByRole("button", { name: "Forgot recovery key?" }).click();
await expect(
page.getByRole("heading", { name: "Forgot your recovery key? Youll need to reset your identity." }),
).toBeVisible();
});
});
+93 -29
View File
@@ -139,14 +139,14 @@ export async function checkDeviceIsCrossSigned(app: ElementAppPage): Promise<voi
* Check that the current device is connected to the expected key backup.
* Also checks that the decryption key is known and cached locally.
*
* @param page - the page to check
* @param app -` ElementAppPage` wrapper for the playwright `Page`.
* @param expectedBackupVersion - the version of the backup we expect to be connected to.
* @param checkBackupKeyInCache - whether to check that the backup key is cached locally.
* @param checkBackupPrivateKeyInCache - whether to check that the backup decryption key is cached locally
*/
export async function checkDeviceIsConnectedKeyBackup(
page: Page,
app: ElementAppPage,
expectedBackupVersion: string,
checkBackupKeyInCache: boolean,
checkBackupPrivateKeyInCache: boolean,
): Promise<void> {
// Sanity check the given backup version: if it's null, something went wrong earlier in the test.
if (!expectedBackupVersion) {
@@ -155,23 +155,48 @@ export async function checkDeviceIsConnectedKeyBackup(
);
}
await page.getByRole("button", { name: "User menu" }).click();
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Security & Privacy" }).click();
await expect(page.locator(".mx_Dialog").getByRole("button", { name: "Restore from Backup" })).toBeVisible();
const backupData = await app.client.evaluate(async (client: MatrixClient) => {
const crypto = client.getCrypto();
if (!crypto) return;
// expand the advanced section to see the active version in the reports
await page.locator(".mx_SecureBackupPanel_advanced").locator("..").click();
const backupInfo = await crypto.getKeyBackupInfo();
const backupKeyIn4S = Boolean(await client.isKeyBackupKeyStored());
const backupPrivateKeyFromCache = await crypto.getSessionBackupPrivateKey();
const hasBackupPrivateKeyFromCache = Boolean(backupPrivateKeyFromCache);
const backupPrivateKeyWellFormed = backupPrivateKeyFromCache instanceof Uint8Array;
const activeBackupVersion = await crypto.getActiveSessionBackupVersion();
if (checkBackupKeyInCache) {
const cacheDecryptionKeyStatusElement = page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(2) td");
await expect(cacheDecryptionKeyStatusElement).toHaveText("cached locally, well formed");
return {
backupInfo,
hasBackupPrivateKeyFromCache,
backupPrivateKeyWellFormed,
backupKeyIn4S,
activeBackupVersion,
};
});
if (!backupData) {
throw new Error("Crypto module is not available");
}
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText(
expectedBackupVersion + " (Algorithm: m.megolm_backup.v1.curve25519-aes-sha2)",
);
const { backupInfo, backupKeyIn4S, hasBackupPrivateKeyFromCache, backupPrivateKeyWellFormed, activeBackupVersion } =
backupData;
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(6) td")).toHaveText(expectedBackupVersion);
// We have a key backup
expect(backupInfo).toBeDefined();
// The key backup version is as expected
expect(backupInfo.version).toBe(expectedBackupVersion);
// The active backup version is as expected
expect(activeBackupVersion).toBe(expectedBackupVersion);
// The backup key is stored in 4S
expect(backupKeyIn4S).toBe(true);
if (checkBackupPrivateKeyInCache) {
// The backup key is available locally
expect(hasBackupPrivateKeyFromCache).toBe(true);
// The backup key is well-formed
expect(backupPrivateKeyWellFormed).toBe(true);
}
}
/**
@@ -189,6 +214,11 @@ export async function logIntoElement(page: Page, credentials: Credentials, secur
// if a securityKey was given, verify the new device
if (securityKey !== undefined) {
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key" }).click();
const useSecurityKey = page.locator(".mx_Dialog").getByRole("button", { name: "use your Security Key" });
if (await useSecurityKey.isVisible()) {
await useSecurityKey.click();
}
// Fill in the security key
await page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey);
await page.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
@@ -216,18 +246,19 @@ export async function logOutOfElement(page: Page, discardKeys: boolean = false)
}
/**
* Open the security settings, and verify the current session using the security key.
* Open the encryption settings, and verify the current session using the security key.
*
* @param app - `ElementAppPage` wrapper for the playwright `Page`.
* @param securityKey - The security key (i.e., 4S key), set up during a previous session.
*/
export async function verifySession(app: ElementAppPage, securityKey: string) {
const settings = await app.settings.openUserSettings("Security & Privacy");
await settings.getByRole("button", { name: "Verify this session" }).click();
const settings = await app.settings.openUserSettings("Encryption");
await settings.getByRole("button", { name: "Verify this device" }).click();
await app.page.getByRole("button", { name: "Verify with Security Key" }).click();
await app.page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey);
await app.page.getByRole("button", { name: "Continue", disabled: false }).click();
await app.page.getByRole("button", { name: "Done" }).click();
await app.settings.closeDialog();
}
/**
@@ -262,19 +293,52 @@ export async function doTwoWaySasVerification(page: Page, verifier: JSHandle<Ver
export async function enableKeyBackup(app: ElementAppPage): Promise<string> {
await app.settings.openUserSettings("Security & Privacy");
await app.page.getByRole("button", { name: "Set up Secure Backup" }).click();
const dialog = app.page.locator(".mx_Dialog");
// Recovery key is selected by default
await dialog.getByRole("button", { name: "Continue" }).click({ timeout: 60000 });
// copy the text ourselves
const securityKey = await dialog.locator(".mx_CreateSecretStorageDialog_recoveryKey code").textContent();
await copyAndContinue(app.page);
return await completeCreateSecretStorageDialog(app.page);
}
await expect(dialog.getByText("Secure Backup successful")).toBeVisible();
await dialog.getByRole("button", { name: "Done" }).click();
await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible();
/**
* Go through the "Set up Secure Backup" dialog (aka the `CreateSecretStorageDialog`).
*
* Assumes the dialog is already open for some reason (see also {@link enableKeyBackup}).
*
* @param page - The playwright `Page` fixture.
* @param opts - Options object
* @param opts.accountPassword - The user's account password. If we are also resetting cross-signing, then we will need
* to upload the public cross-signing keys, which will cause the app to prompt for the password.
*
* @returns the new recovery key.
*/
export async function completeCreateSecretStorageDialog(
page: Page,
opts?: { accountPassword?: string },
): Promise<string> {
const currentDialogLocator = page.locator(".mx_Dialog");
return securityKey;
await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible();
// "Generate a Security Key" is selected by default
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click();
// copy the recovery key to use it later
const recoveryKey = await page.evaluate(() => navigator.clipboard.readText());
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
// If the device is unverified, there should be a "Setting up keys" step.
// If this is not the first time we are setting up cross-signing, the app will prompt for our password; otherwise
// the step is quite quick, and playwright can miss it, so we can't test for it.
if (opts && Object.hasOwn(opts, "accountPassword")) {
await expect(currentDialogLocator.getByRole("heading", { name: "Setting up keys" })).toBeVisible();
await page.getByPlaceholder("Password").fill(opts!.accountPassword);
await currentDialogLocator.getByRole("button", { name: "Continue" }).click();
}
// Either way, we end up at a success dialog:
await expect(currentDialogLocator.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Done", exact: true }).click();
await expect(currentDialogLocator.getByText("Secure Backup successful")).not.toBeVisible();
return recoveryKey;
}
/**
+7 -7
View File
@@ -6,14 +6,14 @@ 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 { API, Messages } from "mailhog";
import { MailpitClient } from "mailpit-api";
import { Page } from "@playwright/test";
import { expect } from "../../element-web-test";
export async function registerAccountMas(
page: Page,
mailhog: API,
mailpit: MailpitClient,
username: string,
email: string,
password: string,
@@ -27,13 +27,13 @@ export async function registerAccountMas(
await page.getByRole("textbox", { name: "Confirm Password" }).fill(password);
await page.getByRole("button", { name: "Continue" }).click();
let messages: Messages;
let code: string;
await expect(async () => {
messages = await mailhog.messages();
expect(messages.items).toHaveLength(1);
const messages = await mailpit.listMessages();
expect(messages.messages[0].To[0].Address).toEqual(email);
const text = await mailpit.renderMessageText(messages.messages[0].ID);
[, code] = text.match(/Your verification code to confirm this email address is: (\d{6})/);
}).toPass();
expect(messages.items[0].to).toEqual(`${username} <${email}>`);
const [, code] = messages.items[0].text.match(/Your verification code to confirm this email address is: (\d{6})/);
await page.getByRole("textbox", { name: "6-digit code" }).fill(code);
await page.getByRole("button", { name: "Continue" }).click();
+2 -2
View File
@@ -19,7 +19,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
context,
page,
homeserver,
mailhogClient,
mailpitClient,
mas,
}, testInfo) => {
await page.clock.install();
@@ -33,7 +33,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
await page.getByRole("button", { name: "Continue" }).click();
const userId = `alice_${testInfo.testId}`;
await registerAccountMas(page, mailhogClient, userId, "alice@email.com", "Pa$sW0rD!");
await registerAccountMas(page, mailpitClient, userId, "alice@email.com", "Pa$sW0rD!");
// Eventually, we should end up at the home screen.
await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 });
+2 -1
View File
@@ -526,9 +526,10 @@ class Helpers {
await expect(threadPanel).toBeVisible();
await threadPanel.evaluate(($panel) => {
const $button = $panel.querySelector<HTMLElement>('[data-testid="base-card-back-button"]');
const title = $panel.querySelector<HTMLElement>(".mx_BaseCard_header_title")?.textContent;
// If the Threads back button is present then click it - the
// threads button can open either threads list or thread panel
if ($button) {
if ($button && title !== "Threads") {
$button.click();
}
});
+6 -5
View File
@@ -34,7 +34,7 @@ test.describe("Email Registration", async () => {
test(
"registers an account and lands on the home page",
{ tag: "@screenshot" },
async ({ page, mailhogClient, request, checkA11y }) => {
async ({ page, mailpitClient, request, checkA11y }) => {
await expect(page.getByRole("textbox", { name: "Username" })).toBeVisible();
// Hide the server text as it contains the randomly allocated Homeserver port
const screenshotOptions = { mask: [page.locator(".mx_ServerPicker_server")] };
@@ -51,10 +51,11 @@ test.describe("Email Registration", async () => {
await expect(page.getByText("An error was encountered when sending the email")).not.toBeVisible();
const messages = await mailhogClient.messages();
expect(messages.items).toHaveLength(1);
expect(messages.items[0].to).toEqual("alice@email.com");
const [emailLink] = messages.items[0].text.match(/http.+/);
const messages = await mailpitClient.listMessages();
expect(messages.messages).toHaveLength(1);
expect(messages.messages[0].To[0].Address).toEqual("alice@email.com");
const text = await mailpitClient.renderMessageText(messages.messages[0].ID);
const [emailLink] = text.match(/http.+/);
await request.get(emailLink); // "Click" the link in the email
await expect(page.getByText("Welcome alice")).toBeVisible();
@@ -42,7 +42,7 @@ test.describe("Memberlist", () => {
await app.viewRoomByName(ROOM_NAME);
const memberlist = await app.toggleMemberlistPanel();
await expect(memberlist.locator(".mx_MemberTileView")).toHaveCount(4);
await expect(memberlist.getByText("(Invited)")).toHaveCount(1);
await expect(memberlist.getByText("Invited")).toHaveCount(1);
await expect(page.locator(".mx_MemberListView")).toMatchScreenshot("with-four-members.png");
});
});
@@ -0,0 +1,73 @@
/*
* 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 "./index";
import { checkDeviceIsCrossSigned } from "../../crypto/utils";
import { bootstrapCrossSigningForClient } from "../../../pages/client";
test.describe("Advanced section in Encryption tab", () => {
test.beforeEach(async ({ page, app, homeserver, credentials, util }) => {
const clientHandle = await app.client.prepareClient();
// Reset cross signing in order to have a verified session
await bootstrapCrossSigningForClient(clientHandle, credentials, true);
});
test("should show the encryption details", { tag: "@screenshot" }, async ({ page, app, util }) => {
await util.openEncryptionTab();
const section = util.getEncryptionDetailsSection();
const deviceId = await page.evaluate(() => window.mxMatrixClientPeg.get().getDeviceId());
await expect(section.getByText(deviceId)).toBeVisible();
await expect(section).toMatchScreenshot("encryption-details.png", {
mask: [section.getByTestId("deviceId"), section.getByTestId("sessionKey")],
});
});
test("should show the import room keys dialog", async ({ page, app, util }) => {
await util.openEncryptionTab();
const section = util.getEncryptionDetailsSection();
await section.getByRole("button", { name: "Import keys" }).click();
await expect(page.getByRole("heading", { name: "Import room keys" })).toBeVisible();
});
test("should show the export room keys dialog", async ({ page, app, util }) => {
await util.openEncryptionTab();
const section = util.getEncryptionDetailsSection();
await section.getByRole("button", { name: "Export keys" }).click();
await expect(page.getByRole("heading", { name: "Export room keys" })).toBeVisible();
});
test(
"should reset the cryptographic identity",
{ tag: "@screenshot" },
async ({ page, app, credentials, util }) => {
const tab = await util.openEncryptionTab();
const section = util.getEncryptionDetailsSection();
await section.getByRole("button", { name: "Reset cryptographic identity" }).click();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("reset-cryptographic-identity.png");
await tab.getByRole("button", { name: "Continue" }).click();
// Fill password dialog and validate
const dialog = page.locator(".mx_InteractiveAuthDialog");
await dialog.getByRole("textbox", { name: "Password" }).fill(credentials.password);
await dialog.getByRole("button", { name: "Continue" }).click();
await expect(section.getByRole("button", { name: "Reset cryptographic identity" })).toBeVisible();
// After resetting the identity, the user should set up a new recovery key
await expect(
util.getEncryptionRecoverySection().getByRole("button", { name: "Set up recovery" }),
).toBeVisible();
await checkDeviceIsCrossSigned(app);
},
);
});
@@ -0,0 +1,96 @@
/*
* 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 { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
import { test, expect } from ".";
import {
checkDeviceIsConnectedKeyBackup,
checkDeviceIsCrossSigned,
createBot,
deleteCachedSecrets,
verifySession,
} from "../../crypto/utils";
test.describe("Encryption tab", () => {
test.use({
displayName: "Alice",
});
let recoveryKey: GeneratedSecretStorageKey;
let expectedBackupVersion: string;
test.beforeEach(async ({ page, homeserver, credentials }) => {
// The bot bootstraps cross-signing, creates a key backup and sets up a recovery key
const res = await createBot(page, homeserver, credentials);
recoveryKey = res.recoveryKey;
expectedBackupVersion = res.expectedBackupVersion;
});
test(
"should show a 'Verify this device' button if the device is unverified",
{ tag: "@screenshot" },
async ({ page, app, util }) => {
const dialog = await util.openEncryptionTab();
const content = util.getEncryptionTabContent();
// The user's device is in an unverified state, therefore the only option available to them here is to verify it
const verifyButton = dialog.getByRole("button", { name: "Verify this device" });
await expect(verifyButton).toBeVisible();
await expect(content).toMatchScreenshot("verify-device-encryption-tab.png");
await verifyButton.click();
await util.verifyDevice(recoveryKey);
await expect(content).toMatchScreenshot("default-tab.png", {
mask: [content.getByTestId("deviceId"), content.getByTestId("sessionKey")],
});
// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);
// Check that the current device is connected to key backup
// The backup decryption key should be in cache also, as we got it directly from the 4S
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
},
);
// Test what happens if the cross-signing secrets are in secret storage but are not cached in the local DB.
//
// This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
// We simulate this case by deleting the cached secrets in the indexedDB.
test(
"should prompt to enter the recovery key when the secrets are not cached locally",
{ tag: "@screenshot" },
async ({ page, app, util }) => {
await verifySession(app, "new passphrase");
// We need to delete the cached secrets
await deleteCachedSecrets(page);
await util.openEncryptionTab();
// We ask the user to enter the recovery key
const dialog = util.getEncryptionTabContent();
const enterKeyButton = dialog.getByRole("button", { name: "Enter recovery key" });
await expect(enterKeyButton).toBeVisible();
await expect(dialog).toMatchScreenshot("out-of-sync-recovery.png");
await enterKeyButton.click();
// Fill the recovery key
await util.enterRecoveryKey(recoveryKey);
await expect(dialog).toMatchScreenshot("default-tab.png", {
mask: [dialog.getByTestId("deviceId"), dialog.getByTestId("sessionKey")],
});
// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);
// Check that the current device is connected to key backup
// The backup decryption key should be in cache also, as we got it directly from the 4S
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
},
);
});
@@ -18,6 +18,8 @@ export { expect };
export const test = base.extend<{
util: Helpers;
}>({
displayName: "Alice",
util: async ({ page, app, bot }, use) => {
await use(new Helpers(page, app));
},
@@ -67,6 +69,20 @@ class Helpers {
return this.page.getByTestId("encryptionTab");
}
/**
* Get the recovery section
*/
getEncryptionRecoverySection() {
return this.page.getByTestId("recoveryPanel");
}
/**
* Get the encryption details section
*/
getEncryptionDetailsSection() {
return this.page.getByTestId("encryptionDetails");
}
/**
* Set the default key id of the secret storage to `null`
*/
@@ -92,6 +108,6 @@ class Helpers {
const clipboardContent = await this.app.getClipboard();
await dialog.getByRole("textbox").fill(clipboardContent);
await dialog.getByRole("button", { name: confirmButtonLabel }).click();
await expect(dialog).toMatchScreenshot("default-recovery.png");
await expect(this.getEncryptionRecoverySection()).toMatchScreenshot("default-recovery.png");
}
}
@@ -5,50 +5,17 @@
* Please see LICENSE files in the repository root for full details.
*/
import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
import { test, expect } from ".";
import {
checkDeviceIsConnectedKeyBackup,
checkDeviceIsCrossSigned,
createBot,
deleteCachedSecrets,
verifySession,
} from "../../crypto/utils";
import { checkDeviceIsConnectedKeyBackup, createBot, verifySession } from "../../crypto/utils";
test.describe("Recovery section in Encryption tab", () => {
test.use({
displayName: "Alice",
});
let recoveryKey: GeneratedSecretStorageKey;
let expectedBackupVersion: string;
test.beforeEach(async ({ page, homeserver, credentials }) => {
const res = await createBot(page, homeserver, credentials);
recoveryKey = res.recoveryKey;
expectedBackupVersion = res.expectedBackupVersion;
});
test("should verify the device", { tag: "@screenshot" }, async ({ page, app, util }) => {
const dialog = await util.openEncryptionTab();
// The user's device is in an unverified state, therefore the only option available to them here is to verify it
const verifyButton = dialog.getByRole("button", { name: "Verify this device" });
await expect(verifyButton).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("verify-device-encryption-tab.png");
await verifyButton.click();
await util.verifyDevice(recoveryKey);
await expect(util.getEncryptionTabContent()).toMatchScreenshot("default-recovery.png");
// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);
// Check that the current device is connected to key backup
// The backup decryption key should be in cache also, as we got it directly from the 4S
await app.closeDialog();
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
// The bot bootstraps cross-signing, creates a key backup and sets up a recovery key
await createBot(page, homeserver, credentials);
});
test(
@@ -61,7 +28,7 @@ test.describe("Recovery section in Encryption tab", () => {
// The user can only change the recovery key
const changeButton = dialog.getByRole("button", { name: "Change recovery key" });
await expect(changeButton).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("default-recovery.png");
await expect(util.getEncryptionRecoverySection()).toMatchScreenshot("default-recovery.png");
await changeButton.click();
// Display the new recovery key and click on the copy button
@@ -89,7 +56,7 @@ test.describe("Recovery section in Encryption tab", () => {
const dialog = await util.openEncryptionTab();
const setupButton = dialog.getByRole("button", { name: "Set up recovery" });
await expect(setupButton).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("set-up-recovery.png");
await expect(util.getEncryptionRecoverySection()).toMatchScreenshot("set-up-recovery.png");
await setupButton.click();
// Display an informative panel about the recovery key
@@ -115,42 +82,7 @@ test.describe("Recovery section in Encryption tab", () => {
// The recovery key is now set up and the user can change it
await expect(dialog.getByRole("button", { name: "Change recovery key" })).toBeVisible();
await app.closeDialog();
// Check that the current device is connected to key backup and the backup version is the expected one
await checkDeviceIsConnectedKeyBackup(page, "1", true);
await checkDeviceIsConnectedKeyBackup(app, "1", true);
});
// Test what happens if the cross-signing secrets are in secret storage but are not cached in the local DB.
//
// This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
// We simulate this case by deleting the cached secrets in the indexedDB.
test(
"should enter the recovery key when the secrets are not cached",
{ tag: "@screenshot" },
async ({ page, app, util }) => {
await verifySession(app, "new passphrase");
// We need to delete the cached secrets
await deleteCachedSecrets(page);
await util.openEncryptionTab();
// We ask the user to enter the recovery key
const dialog = util.getEncryptionTabContent();
const enterKeyButton = dialog.getByRole("button", { name: "Enter recovery key" });
await expect(enterKeyButton).toBeVisible();
await expect(dialog).toMatchScreenshot("out-of-sync-recovery.png");
await enterKeyButton.click();
// Fill the recovery key
await util.enterRecoveryKey(recoveryKey);
await expect(dialog).toMatchScreenshot("default-recovery.png");
// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);
// Check that the current device is connected to key backup
// The backup decryption key should be in cache also, as we got it directly from the 4S
await app.closeDialog();
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
},
);
});
-1
View File
@@ -192,7 +192,6 @@ export class Bot extends Client {
await clientHandle.evaluate(async (cli) => {
await cli.initRustCrypto({ useIndexedDB: false });
cli.setGlobalErrorOnUnknownDevices(false);
await cli.startClient();
});
@@ -10,7 +10,7 @@ import { Fixtures } from "../../../element-web-test.ts";
export const consentHomeserver: Fixtures = {
_homeserver: [
async ({ _homeserver: container, mailhog }, use) => {
async ({ _homeserver: container, mailpit }, use) => {
container
.withCopyDirectoriesToContainer([
{ source: "playwright/plugins/homeserver/synapse/res", target: "/data/res" },
@@ -18,7 +18,7 @@ export const consentHomeserver: Fixtures = {
.withConfig({
email: {
enable_notifs: false,
smtp_host: "mailhog",
smtp_host: "mailpit",
smtp_port: 1025,
smtp_user: "username",
smtp_pass: "password",
@@ -10,13 +10,13 @@ import { Fixtures } from "../../../element-web-test.ts";
export const emailHomeserver: Fixtures = {
_homeserver: [
async ({ _homeserver: container, mailhog }, use) => {
async ({ _homeserver: container, mailpit }, use) => {
container.withConfig({
enable_registration_without_verification: undefined,
disable_msisdn_registration: undefined,
registrations_require_3pid: ["email"],
email: {
smtp_host: "mailhog",
smtp_host: "mailpit",
smtp_port: 1025,
notif_from: "Your Friendly %(app)s homeserver <noreply@example.com>",
app_name: "my_branded_matrix_server",
@@ -11,7 +11,7 @@ import { Fixtures } from "../../../element-web-test.ts";
export const masHomeserver: Fixtures = {
mas: [
async ({ _homeserver: homeserver, logger, network, postgres, mailhog }, use) => {
async ({ _homeserver: homeserver, logger, network, postgres, mailpit }, use) => {
const config = {
clients: [
{
+9 -9
View File
@@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details.
*/
import { test as base } from "@playwright/test";
import mailhog from "mailhog";
import { MailpitClient } from "mailpit-api";
import { Network, StartedNetwork } from "testcontainers";
import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql";
@@ -14,13 +14,13 @@ import { SynapseConfig, SynapseContainer } from "./testcontainers/synapse.ts";
import { Logger } from "./logger.ts";
import { StartedMatrixAuthenticationServiceContainer } from "./testcontainers/mas.ts";
import { HomeserverContainer, StartedHomeserverContainer } from "./testcontainers/HomeserverContainer.ts";
import { MailhogContainer, StartedMailhogContainer } from "./testcontainers/mailhog.ts";
import { MailhogContainer, StartedMailhogContainer } from "./testcontainers/mailpit.ts";
import { OAuthServer } from "./plugins/oauth_server";
import { DendriteContainer, PineconeContainer } from "./testcontainers/dendrite.ts";
import { HomeserverType } from "./plugins/homeserver";
export interface TestFixtures {
mailhogClient: mailhog.API;
mailpitClient: MailpitClient;
}
export interface Services {
@@ -28,7 +28,7 @@ export interface Services {
network: StartedNetwork;
postgres: StartedPostgreSqlContainer;
mailhog: StartedMailhogContainer;
mailpit: StartedMailhogContainer;
synapseConfig: SynapseConfig;
_homeserver: HomeserverContainer<any>;
@@ -90,20 +90,20 @@ export const test = base.extend<TestFixtures, Services & Options>({
{ scope: "worker" },
],
mailhog: [
mailpit: [
async ({ logger, network }, use) => {
const container = await new MailhogContainer()
.withNetwork(network)
.withNetworkAliases("mailhog")
.withLogConsumer(logger.getConsumer("mailhog"))
.withNetworkAliases("mailpit")
.withLogConsumer(logger.getConsumer("mailpit"))
.start();
await use(container);
await container.stop();
},
{ scope: "worker" },
],
mailhogClient: async ({ mailhog: container }, use) => {
await container.client.deleteAll();
mailpitClient: async ({ mailpit: container }, use) => {
await container.client.deleteMessages();
await use(container.client);
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

@@ -6,13 +6,16 @@ Please see LICENSE files in the repository root for full details.
*/
import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers";
import mailhog from "mailhog";
import { MailpitClient } from "mailpit-api";
export class MailhogContainer extends GenericContainer {
constructor() {
super("mailhog/mailhog:latest");
super("axllent/mailpit:latest");
this.withExposedPorts(8025).withWaitStrategy(Wait.forListeningPorts());
this.withExposedPorts(8025).withWaitStrategy(Wait.forListeningPorts()).withEnvironment({
MP_SMTP_AUTH_ALLOW_INSECURE: "true",
MP_SMTP_AUTH_ACCEPT_ANY: "true",
});
}
public override async start(): Promise<StartedMailhogContainer> {
@@ -21,10 +24,10 @@ export class MailhogContainer extends GenericContainer {
}
export class StartedMailhogContainer extends AbstractStartedContainer {
public readonly client: mailhog.API;
public readonly client: MailpitClient;
constructor(container: StartedTestContainer) {
super(container);
this.client = mailhog({ host: container.getHost(), port: container.getMappedPort(8025) });
this.client = new MailpitClient(`http://${container.getHost()}:${container.getMappedPort(8025)}`);
}
}
+1 -1
View File
@@ -92,7 +92,7 @@ const DEFAULT_CONFIG = {
reply_to: '"Authentication Service" <root@localhost>',
transport: "smtp",
mode: "plain",
hostname: "mailhog",
hostname: "mailpit",
port: 1025,
username: "username",
password: "password",
+1 -1
View File
@@ -19,7 +19,7 @@ import { HomeserverContainer, StartedHomeserverContainer } from "./HomeserverCon
import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts";
import { Api, ClientServerApi, Verb } from "../plugins/utils/api.ts";
const TAG = "develop@sha256:5d62b61c4373eaca25df6c6bb99fc1be92f8f40b8abebd8897bf5b2af9eb137a";
const TAG = "develop@sha256:098126c6be750dffaff5bd19db254609aadaf34f76c70f2dca9821cb12428613";
const DEFAULT_CONFIG = {
server_name: "localhost",
+3
View File
@@ -134,6 +134,7 @@
@import "./views/dialogs/_ConfirmUserActionDialog.pcss";
@import "./views/dialogs/_CreateRoomDialog.pcss";
@import "./views/dialogs/_CreateSubspaceDialog.pcss";
@import "./views/dialogs/_Crypto.pcss";
@import "./views/dialogs/_DeactivateAccountDialog.pcss";
@import "./views/dialogs/_DevtoolsDialog.pcss";
@import "./views/dialogs/_ExportDialog.pcss";
@@ -354,8 +355,10 @@
@import "./views/settings/_ThemeChoicePanel.pcss";
@import "./views/settings/_UpdateCheckButton.pcss";
@import "./views/settings/_UserProfileSettings.pcss";
@import "./views/settings/encryption/_AdvancedPanel.pcss";
@import "./views/settings/encryption/_ChangeRecoveryKey.pcss";
@import "./views/settings/encryption/_EncryptionCard.pcss";
@import "./views/settings/encryption/_ResetIdentityPanel.pcss";
@import "./views/settings/tabs/_SettingsBanner.pcss";
@import "./views/settings/tabs/_SettingsIndent.pcss";
@import "./views/settings/tabs/_SettingsSection.pcss";
-29
View File
@@ -37,27 +37,6 @@ Please see LICENSE files in the repository root for full details.
line-height: $font-24px;
margin-left: 10px;
}
.mx_UserMenu_dndBadge {
position: absolute;
bottom: -2px;
right: -7px;
width: 16px;
height: 16px;
border-radius: 50%;
&::before {
content: "";
width: 16px;
height: 16px;
position: absolute;
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
background-color: $alert;
mask-image: url("$(res)/img/element-icons/roomlist/dnd.svg");
}
}
}
.mx_IconizedContextMenu {
@@ -158,14 +137,6 @@ Please see LICENSE files in the repository root for full details.
mask-image: url("@vector-im/compound-design-tokens/icons/home-solid.svg");
}
.mx_UserMenu_iconDnd::before {
mask-image: url("$(res)/img/element-icons/roomlist/dnd.svg");
}
.mx_UserMenu_iconDndOff::before {
mask-image: url("$(res)/img/element-icons/roomlist/dnd-cross.svg");
}
.mx_UserMenu_iconBell::before {
mask-image: url("$(res)/img/element-icons/notifications.svg");
}
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.mx_Crypto {
table {
margin: var(--cpd-space-4x) 0;
text-align: left;
border-spacing: var(--cpd-space-2x) 0;
thead {
font: var(--cpd-font-heading-sm-semibold);
}
}
}
+4 -2
View File
@@ -26,7 +26,8 @@ Please see LICENSE files in the repository root for full details.
}
&.mx_UserPill_me,
&.mx_AtRoomPill {
&.mx_AtRoomPill,
&.mx_KeywordPill {
background-color: var(--cpd-color-bg-critical-primary) !important; /* To override .markdown-body */
}
@@ -45,7 +46,8 @@ Please see LICENSE files in the repository root for full details.
}
/* We don't want to indicate clickability */
&.mx_AtRoomPill:hover {
&.mx_AtRoomPill:hover,
&.mx_KeywordPill:hover {
background-color: var(--cpd-color-bg-critical-primary) !important; /* To override .markdown-body */
cursor: unset;
}
@@ -35,6 +35,8 @@ Please see LICENSE files in the repository root for full details.
.mx_DisambiguatedProfile_mxid {
margin-inline-start: 0;
font: var(--cpd-font-body-sm-regular);
text-overflow: ellipsis;
overflow: hidden;
}
span:not(.mx_DisambiguatedProfile_mxid) {
+40 -33
View File
@@ -15,41 +15,48 @@ Please see LICENSE files in the repository root for full details.
flex: unset;
}
.mx_BaseCard_header {
.mx_BaseCard_header_title {
.mx_AccessibleButton {
font-size: 12px;
color: $secondary-content;
.mx_ThreadPanelHeader {
height: 60px;
display: flex;
box-sizing: border-box;
padding: 16px;
align-items: center;
border-bottom: 1px solid var(--cpd-color-gray-400);
.mx_AccessibleButton {
font-size: 12px;
color: $secondary-content;
}
.mx_ThreadPanel_vertical_separator {
height: 28px;
margin-left: var(--cpd-space-3x);
margin-right: var(--cpd-space-2x);
border-left: 1px solid var(--cpd-color-gray-400);
}
.mx_ThreadPanel_dropdown {
font: var(--cpd-font-body-sm-regular);
padding: 3px $spacing-4 3px $spacing-8;
border-radius: 4px;
line-height: 1.5;
user-select: none;
&:hover,
&[aria-expanded="true"] {
background: $quinary-content;
}
.mx_ThreadPanel_vertical_separator {
height: 16px;
margin-left: var(--cpd-space-3x);
margin-right: var(--cpd-space-1x);
border-left: 1px solid var(--cpd-color-gray-400);
}
.mx_ThreadPanel_dropdown {
padding: 3px $spacing-4 3px $spacing-8;
border-radius: 4px;
line-height: 1.5;
user-select: none;
&:hover,
&[aria-expanded="true"] {
background: $quinary-content;
}
&::before {
content: "";
width: 18px;
height: 18px;
background: currentColor;
mask-image: url("@vector-im/compound-design-tokens/icons/chevron-down.svg");
mask-size: 100%;
mask-repeat: no-repeat;
float: right;
}
&::before {
margin-left: 2px;
content: "";
width: 20px;
height: 20px;
background: currentColor;
mask-image: url("@vector-im/compound-design-tokens/icons/chevron-down.svg");
mask-size: 100%;
mask-repeat: no-repeat;
float: right;
}
}
}
+50 -3
View File
@@ -34,7 +34,7 @@ Please see LICENSE files in the repository root for full details.
}
.mx_UserInfo_container {
padding: var(--cpd-space-4x) 0;
padding: var(--cpd-space-2x) 0 var(--cpd-space-4x);
margin: 0 var(--cpd-space-4x);
.mx_UserInfo_container_verifyButton {
@@ -65,7 +65,7 @@ Please see LICENSE files in the repository root for full details.
}
.mx_UserInfo_avatar {
margin: $spacing-24 $spacing-32 0 $spacing-32;
margin: var(--cpd-space-12x) var(--cpd-space-4x) 0 var(--cpd-space-4x);
.mx_UserInfo_avatar_transition {
max-width: 120px;
@@ -98,8 +98,18 @@ Please see LICENSE files in the repository root for full details.
margin: 5px 0;
}
.mx_UserInfo_header {
margin-bottom: var(--cpd-space-8x);
padding-bottom: 0;
}
.mx_UserInfo_profile {
display: flex;
flex-direction: column;
gap: var(--cpd-space-1x);
h1 {
margin: 0;
font-size: $font-20px;
line-height: $font-25px;
@@ -119,8 +129,45 @@ Please see LICENSE files in the repository root for full details.
}
}
.mx_UserInfo_profile_name {
height: 30px;
}
.mx_UserInfo_profile_mxid {
color: var(--cpd-color-text-secondary);
height: 28px;
}
.mx_UserInfo_profileStatus {
margin: var(--cpd-space-1x) 0;
height: 20px;
}
.mx_UserInfo_timezone {
height: 20px;
margin: 0;
display: flex;
align-items: center;
}
/** Overrides for the copy to clipboard button **/
.mx_CopyableText {
align-items: center;
}
.mx_CopyableText_copyButton {
width: 28px;
height: 28px;
display: flex;
justify-content: center;
align-items: center;
position: unset;
padding-left: var(--cpd-space-2x);
}
.mx_CopyableText_copyButton::before {
width: 20px;
height: 20px;
background-color: var(--cpd-color-icon-secondary-alpha);
}
}
-6
View File
@@ -135,12 +135,6 @@ $left-gutter: 64px;
}
}
&.mx_EventTile_highlight,
&.mx_EventTile_highlight .markdown-body,
&.mx_EventTile_highlight .mx_EventTile_edited {
color: $alert;
}
&.mx_EventTile_bubbleContainer {
display: grid;
grid-template-columns: 1fr 100px;
+1
View File
@@ -35,6 +35,7 @@ Please see LICENSE files in the repository root for full details.
font: var(--cpd-font-body-sm-regular);
font-size: 13px;
color: var(--cpd-color-text-secondary);
margin-left: var(--cpd-space-4x);
}
.mx_MemberTileView_avatar {
+11
View File
@@ -71,6 +71,7 @@ Please see LICENSE files in the repository root for full details.
padding: var(--cpd-space-1-5x);
cursor: pointer;
user-select: none;
font: var(--cpd-font-body-sm-medium);
/* RoomAvatar doesn't pass classes down to avatar
So set style here
@@ -83,6 +84,12 @@ Please see LICENSE files in the repository root for full details.
color: $primary-content;
background: var(--cpd-color-bg-subtle-primary);
}
&.mx_FacePile_toggled {
background: var(--cpd-color-bg-success-subtle);
color: var(--cpd-color-text-action-accent);
font: var(--cpd-font-body-sm-semibold);
}
}
.mx_RoomHeader .mx_BaseAvatar {
@@ -93,3 +100,7 @@ Please see LICENSE files in the repository root for full details.
/* Workaround for https://github.com/element-hq/compound/issues/331 */
min-width: 240px;
}
.mx_RoomHeader .mx_RoomHeader_toggled {
color: var(--cpd-color-icon-accent-primary);
}
@@ -20,8 +20,14 @@ Please see LICENSE files in the repository root for full details.
margin-left: var(--cpd-space-6x);
flex-grow: 1;
}
.mx_UserIdentityWarning_main.critical {
color: var(--cpd-color-text-critical-primary);
}
}
}
.mx_UserIdentityWarning.critical {
background: linear-gradient(180deg, var(--cpd-color-red-100) 0%, var(--cpd-color-theme-bg) 100%);
}
.mx_MessageComposer.mx_MessageComposer--compact > .mx_UserIdentityWarning {
margin-left: calc(-25px + var(--RoomView_MessageList-padding));
@@ -0,0 +1,51 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.mx_EncryptionDetails,
.mx_OtherSettings {
display: flex;
flex-direction: column;
gap: var(--cpd-space-6x);
width: 100%;
align-items: start;
.mx_EncryptionDetails_session_title,
.mx_OtherSettings_title {
font: var(--cpd-font-body-lg-semibold);
padding-bottom: var(--cpd-space-2x);
border-bottom: 1px solid var(--cpd-color-gray-400);
width: 100%;
margin: 0;
}
}
.mx_EncryptionDetails {
.mx_EncryptionDetails_session {
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
width: 100%;
> div {
display: flex;
> span {
width: 50%;
word-wrap: break-word;
}
}
> div:nth-child(odd) {
background-color: var(--cpd-color-gray-200);
}
}
.mx_EncryptionDetails_buttons {
display: flex;
gap: var(--cpd-space-4x);
}
}
@@ -0,0 +1,26 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.mx_ResetIdentityPanel {
.mx_ResetIdentityPanel_content {
display: flex;
flex-direction: column;
gap: var(--cpd-space-3x);
> span {
font: var(--cpd-font-body-md-medium);
text-align: center;
}
}
.mx_ResetIdentityPanel_footer {
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
justify-content: center;
}
}
Binary file not shown.
@@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.3333 7.3335V8.66683H10.5533L13.56 11.6735C14.26 10.6202 14.6667 9.36016 14.6667 8.00016C14.6667 4.32016 11.68 1.3335 8.00001 1.3335C6.64001 1.3335 5.38001 1.74016 4.32668 2.44016L9.22001 7.3335H11.3333ZM0.926682 2.8135L2.44001 4.32683C1.74001 5.38016 1.33335 6.64016 1.33335 8.00016C1.33335 11.6802 4.32001 14.6668 8.00001 14.6668C9.36001 14.6668 10.62 14.2602 11.6733 13.5602L13.1867 15.0735L14.1267 14.1335L1.87335 1.8735L0.926682 2.8135ZM4.66668 7.3335H5.44668L6.78001 8.66683H4.66668V7.3335Z" fill="#737D8C"/>
</svg>

Before

Width:  |  Height:  |  Size: 630 B

-3
View File
@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM17 13H7V11H17V13Z" fill="#17191C"/>
</svg>

Before

Width:  |  Height:  |  Size: 241 B

+7
View File
@@ -72,6 +72,13 @@ if [[ "$head" == *":"* ]]; then
fi
clone ${TRY_ORG} $defrepo ${TRY_BRANCH}
# For merge queue runs we need to extract the temporary branch name
# the ref_name will look like `gh-readonly-queue/<branch>/pr-<number>-<sha>`
if [[ "$GITHUB_EVENT_NAME" == "merge_group" ]]; then
withoutPrefix=${GITHUB_REF_NAME#gh-readonly-queue/}
clone $deforg $defrepo ${withoutPrefix%%/pr-*}
fi
# Try the target branch of the push or PR.
if [ -n "$GITHUB_BASE_REF" ]; then
clone $deforg $defrepo $GITHUB_BASE_REF
+44 -43
View File
@@ -31,49 +31,50 @@ export async function createCrossSigning(cli: MatrixClient): Promise<void> {
throw new Error("No crypto API found!");
}
const doBootstrapUIAuth = async (
makeRequest: (authData: AuthDict) => Promise<UIAResponse<void>>,
): Promise<void> => {
try {
await makeRequest({});
} catch (error) {
if (!(error instanceof MatrixError) || !error.data || !error.data.flows) {
// Not a UIA response
throw error;
}
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("auth|uia|sso_title"),
body: _t("auth|uia|sso_preauth_body"),
continueText: _t("auth|sso"),
continueKind: "primary",
},
[SSOAuthEntry.PHASE_POSTAUTH]: {
title: _t("encryption|confirm_encryption_setup_title"),
body: _t("encryption|confirm_encryption_setup_body"),
continueText: _t("action|confirm"),
continueKind: "primary",
},
};
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
title: _t("encryption|bootstrap_title"),
matrixClient: cli,
makeRequest,
aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
},
});
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Cross-signing key upload auth canceled");
}
}
};
await cryptoApi.bootstrapCrossSigning({
authUploadDeviceSigningKeys: doBootstrapUIAuth,
authUploadDeviceSigningKeys: (makeRequest) => uiAuthCallback(cli, makeRequest),
});
}
export async function uiAuthCallback(
matrixClient: MatrixClient,
makeRequest: (authData: AuthDict) => Promise<UIAResponse<void>>,
): Promise<void> {
try {
await makeRequest({});
} catch (error) {
if (!(error instanceof MatrixError) || !error.data || !error.data.flows) {
// Not a UIA response
throw error;
}
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("auth|uia|sso_title"),
body: _t("auth|uia|sso_preauth_body"),
continueText: _t("auth|sso"),
continueKind: "primary",
},
[SSOAuthEntry.PHASE_POSTAUTH]: {
title: _t("encryption|confirm_encryption_setup_title"),
body: _t("encryption|confirm_encryption_setup_body"),
continueText: _t("action|confirm"),
continueKind: "primary",
},
};
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
title: _t("encryption|bootstrap_title"),
matrixClient,
makeRequest,
aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
},
});
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Cross-signing key upload auth canceled");
}
}
}
+8 -2
View File
@@ -15,7 +15,7 @@ import {
SyncState,
ClientStoppedError,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { logger as baseLogger } from "matrix-js-sdk/src/logger";
import { CryptoEvent, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import { CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange";
@@ -48,6 +48,8 @@ import { asyncSomeParallel } from "./utils/arrays.ts";
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
const logger = baseLogger.getChild("DeviceListener:");
export default class DeviceListener {
private dispatcherRef?: string;
// device IDs for which the user has dismissed the verify toast ('Later')
@@ -131,7 +133,7 @@ export default class DeviceListener {
* @param {String[]} deviceIds List of device IDs to dismiss notifications for
*/
public async dismissUnverifiedSessions(deviceIds: Iterable<string>): Promise<void> {
logger.log("Dismissing unverified sessions: " + Array.from(deviceIds).join(","));
logger.debug("Dismissing unverified sessions: " + Array.from(deviceIds).join(","));
for (const d of deviceIds) {
this.dismissed.add(d);
}
@@ -309,16 +311,20 @@ export default class DeviceListener {
if (!crossSigningReady) {
// This account is legacy and doesn't have cross-signing set up at all.
// Prompt the user to set it up.
logger.info("Cross-signing not ready: showing SET_UP_ENCRYPTION toast");
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
} else if (!isCurrentDeviceTrusted) {
// cross signing is ready but the current device is not trusted: prompt the user to verify
logger.info("Current device not verified: showing VERIFY_THIS_SESSION toast");
showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION);
} else if (!allCrossSigningSecretsCached) {
// cross signing ready & device trusted, but we are missing secrets from our local cache.
// prompt the user to enter their recovery key.
logger.info("Some secrets not cached: showing KEY_STORAGE_OUT_OF_SYNC toast");
showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC);
} else if (defaultKeyId === null) {
// the user just hasn't set up 4S yet: prompt them to do so
logger.info("No default 4S key: showing SET_UP_RECOVERY toast");
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
} else {
// some other condition... yikes! Show the 'set up encryption' toast: this is what we previously did
+15 -1
View File
@@ -41,6 +41,7 @@ import PlatformPeg from "./PlatformPeg";
import { formatList } from "./utils/FormattingUtils";
import SdkConfig from "./SdkConfig";
import { setDeviceIsolationMode } from "./settings/controllers/DeviceIsolationModeController.ts";
import { initialiseDehydration } from "./utils/device/dehydration";
export interface IMatrixClientCreds {
homeserverUrl: string;
@@ -340,7 +341,20 @@ class MatrixClientPegClass implements IMatrixClientPeg {
setDeviceIsolationMode(this.matrixClient, SettingsStore.getValue("feature_exclude_insecure_devices"));
// TODO: device dehydration and whathaveyou
// Start dehydration. This code is only for the case where the client
// gets restarted, so we only do this if we already have the dehydration
// key cached, and we don't have to try to rehydrate a device. If this
// is a new login, we will start dehydration after Secret Storage is
// unlocked.
try {
await initialiseDehydration({ onlyIfKeyCached: true, rehydrate: false }, this.matrixClient);
} catch (e) {
// We may get an error dehydrating, such as if cross-signing and
// SSSS are not set up yet. Just log the error and continue.
// If SSSS gets set up later, we will re-try dehydration.
console.log("Error starting device dehydration", e);
}
return;
}
+38 -28
View File
@@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details.
import { lazy } from "react";
import { SecretStorage } from "matrix-js-sdk/src/matrix";
import { deriveRecoveryKeyFromPassphrase, decodeRecoveryKey, CryptoCallbacks } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger";
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
import Modal from "./Modal";
import { MatrixClientPeg } from "./MatrixClientPeg";
@@ -29,6 +29,8 @@ let secretStorageKeys: Record<string, Uint8Array> = {};
let secretStorageKeyInfo: Record<string, SecretStorage.SecretStorageKeyDescription> = {};
let secretStorageBeingAccessed = false;
const logger = rootLogger.getChild("SecurityManager:");
/**
* This can be used by other components to check if secret storage access is in
* progress, so that we can e.g. avoid intermittently showing toasts during
@@ -70,33 +72,34 @@ function makeInputToKey(
};
}
async function getSecretStorageKey({
keys: keyInfos,
}: {
keys: Record<string, SecretStorage.SecretStorageKeyDescription>;
}): Promise<[string, Uint8Array]> {
async function getSecretStorageKey(
{
keys: keyInfos,
}: {
keys: Record<string, SecretStorage.SecretStorageKeyDescription>;
},
secretName: string,
): Promise<[string, Uint8Array]> {
const cli = MatrixClientPeg.safeGet();
let keyId = await cli.secretStorage.getDefaultKeyId();
let keyInfo!: SecretStorage.SecretStorageKeyDescription;
if (keyId) {
// use the default SSSS key if set
keyInfo = keyInfos[keyId];
if (!keyInfo) {
// if the default key is not available, pretend the default key
// isn't set
keyId = null;
}
}
if (!keyId) {
// if no default SSSS key is set, fall back to a heuristic of using the
const defaultKeyId = await cli.secretStorage.getDefaultKeyId();
let keyId: string;
// If the defaultKey is useful, use that
if (defaultKeyId && keyInfos[defaultKeyId]) {
keyId = defaultKeyId;
} else {
// Fall back to a heuristic of using the
// only available key, if only one key is set
const keyInfoEntries = Object.entries(keyInfos);
if (keyInfoEntries.length > 1) {
const usefulKeys = Object.keys(keyInfos);
if (usefulKeys.length > 1) {
throw new Error("Multiple storage key requests not implemented");
}
[keyId, keyInfo] = keyInfoEntries[0];
keyId = usefulKeys[0];
}
logger.debug(`getSecretStorageKey: request for 4S keys [${Object.keys(keyInfos)}]: looking for key ${keyId}`);
const keyInfo = keyInfos[keyId];
logger.debug(
`getSecretStorageKey: request for 4S keys [${Object.keys(keyInfos)}] for secret \`${secretName}\`: looking for key ${keyId}`,
);
// Check the in-memory cache
if (secretStorageBeingAccessed && secretStorageKeys[keyId]) {
@@ -106,12 +109,18 @@ async function getSecretStorageKey({
const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.getSecretStorageKey();
if (keyFromCustomisations) {
logger.log("getSecretStorageKey: Using secret storage key from CryptoSetupExtension");
logger.debug("getSecretStorageKey: Using secret storage key from CryptoSetupExtension");
cacheSecretStorageKey(keyId, keyInfo, keyFromCustomisations);
return [keyId, keyFromCustomisations];
}
logger.debug("getSecretStorageKey: prompting user for key");
// We only prompt the user for the default key
if (keyId !== defaultKeyId) {
logger.debug(`getSecretStorageKey: request for non-default key ${keyId}: not prompting user`);
throw new Error("Request for non-default 4S key");
}
logger.debug(`getSecretStorageKey: prompting user for key ${keyId}`);
const inputToKey = makeInputToKey(keyInfo);
const { finished } = Modal.createDialog(
AccessSecretStorageDialog,
@@ -139,7 +148,7 @@ async function getSecretStorageKey({
if (!keyParams) {
throw new AccessCancelledError();
}
logger.debug("getSecretStorageKey: got key from user");
logger.debug(`getSecretStorageKey: got key ${keyId} from user`);
const key = await inputToKey(keyParams);
// Save to cache to avoid future prompts in the current session
@@ -154,6 +163,7 @@ function cacheSecretStorageKey(
key: Uint8Array,
): void {
if (secretStorageBeingAccessed) {
logger.debug(`Caching 4S key ${keyId}`);
secretStorageKeys[keyId] = key;
secretStorageKeyInfo[keyId] = keyInfo;
}
@@ -173,13 +183,13 @@ export const crossSigningCallbacks: CryptoCallbacks = {
* @param func - The operation to be wrapped.
*/
export async function withSecretStorageKeyCache<T>(func: () => Promise<T>): Promise<T> {
logger.debug("SecurityManager: enabling 4S key cache");
logger.debug("enabling 4S key cache");
secretStorageBeingAccessed = true;
try {
return await func();
} finally {
// Clear secret storage key cache now that work is complete
logger.debug("SecurityManager: disabling 4S key cache");
logger.debug("disabling 4S key cache");
secretStorageBeingAccessed = false;
secretStorageKeys = {};
secretStorageKeyInfo = {};
+12 -21
View File
@@ -7,11 +7,12 @@ Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import { SERVICE_TYPES, MatrixClient } from "matrix-js-sdk/src/matrix";
import { SERVICE_TYPES, MatrixClient, Terms, Policy, InternationalisedPolicy } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import Modal from "./Modal";
import TermsDialog from "./components/views/dialogs/TermsDialog";
import { pickBestLanguage } from "./languageHandler.tsx";
export class TermsNotSignedError extends Error {}
@@ -32,23 +33,8 @@ export class Service {
) {}
}
export interface LocalisedPolicy {
name: string;
url: string;
}
export interface Policy {
// @ts-ignore: No great way to express indexed types together with other keys
version: string;
[lang: string]: LocalisedPolicy;
}
export type Policies = {
[policy: string]: Policy;
};
export type ServicePolicyPair = {
policies: Policies;
policies: Terms["policies"];
service: Service;
};
@@ -58,6 +44,11 @@ export type TermsInteractionCallback = (
extraClassNames?: string,
) => Promise<string[]>;
export function pickBestPolicyLanguage(policy: Policy): InternationalisedPolicy | undefined {
const termsLang = pickBestLanguage(Object.keys(policy).filter((k) => k !== "version"));
return <InternationalisedPolicy>policy[termsLang];
}
/**
* Start a flow where the user is presented with terms & conditions for some services
*
@@ -96,7 +87,7 @@ export async function startTermsFlow(
* }
*/
const terms: { policies: Policies }[] = await Promise.all(termsPromises);
const terms: Terms[] = await Promise.all(termsPromises);
const policiesAndServicePairs = terms.map((t, i) => {
return { service: services[i], policies: t.policies };
});
@@ -113,11 +104,11 @@ export async function startTermsFlow(
// things they've not agreed to yet.
const unagreedPoliciesAndServicePairs: ServicePolicyPair[] = [];
for (const { service, policies } of policiesAndServicePairs) {
const unagreedPolicies: Policies = {};
const unagreedPolicies: Terms["policies"] = {};
for (const [policyName, policy] of Object.entries(policies)) {
let policyAgreed = false;
for (const lang of Object.keys(policy)) {
if (lang === "version") continue;
if (lang === "version" || typeof policy[lang] === "string") continue;
if (agreedUrlSet.has(policy[lang].url)) {
policyAgreed = true;
break;
@@ -154,7 +145,7 @@ export async function startTermsFlow(
const urlsForService = Array.from(agreedUrlSet).filter((url) => {
for (const policy of Object.values(policiesAndService.policies)) {
for (const lang of Object.keys(policy)) {
if (lang === "version") continue;
if (lang === "version" || typeof policy[lang] === "string") continue;
if (policy[lang].url === url) return true;
}
}
@@ -332,7 +332,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
setupNewKeyBackup: !backupInfo,
});
}
await initialiseDehydration(true);
await initialiseDehydration({ createNewKey: true });
this.setState({
phase: Phase.Stored,
+4 -8
View File
@@ -50,7 +50,7 @@ import ThemeController from "../../settings/controllers/ThemeController";
import { startAnyRegistrationFlow } from "../../Registration";
import ResizeNotifier from "../../utils/ResizeNotifier";
import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils";
import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher";
import { FontWatcher } from "../../settings/watchers/FontWatcher";
import { storeRoomAliasInCache } from "../../RoomAliasCache";
import ToastStore from "../../stores/ToastStore";
@@ -131,6 +131,7 @@ import { ConfirmSessionLockTheftView } from "./auth/ConfirmSessionLockTheftView"
import { LoginSplashView } from "./auth/LoginSplashView";
import { cleanUpDraftsIfRequired } from "../../DraftCleaner";
import { InitialCryptoSetupStore } from "../../stores/InitialCryptoSetupStore";
import { setTheme } from "../../theme";
// legacy export
export { default as Views } from "../../Views";
@@ -463,6 +464,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.themeWatcher = new ThemeWatcher();
this.fontWatcher = new FontWatcher();
this.themeWatcher.start();
this.themeWatcher.on(ThemeWatcherEvent.Change, setTheme);
this.fontWatcher.start();
initSentry(SdkConfig.get("sentry"));
@@ -495,6 +497,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
public componentWillUnmount(): void {
Lifecycle.stopMatrixClient();
dis.unregister(this.dispatcherRef);
this.themeWatcher?.off(ThemeWatcherEvent.Change, setTheme);
this.themeWatcher?.stop();
this.fontWatcher?.stop();
UIStore.destroy();
@@ -1695,13 +1698,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (crypto) {
const blacklistEnabled = SettingsStore.getValueAt(SettingLevel.DEVICE, "blacklistUnverifiedDevices");
crypto.globalBlacklistUnverifiedDevices = blacklistEnabled;
// With cross-signing enabled, we send to unknown devices
// without prompting. Any bad-device status the user should
// be aware of will be signalled through the room shield
// changing colour. More advanced behaviour will come once
// we implement more settings.
cli.setGlobalErrorOnUnknownDevices(false);
}
// Cannot be done in OnLoggedIn as at that point the AccountSettingsHandler doesn't yet have a client
+1 -1
View File
@@ -66,7 +66,7 @@ import RoomPreviewBar from "../views/rooms/RoomPreviewBar";
import RoomPreviewCard from "../views/rooms/RoomPreviewCard";
import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
import AuxPanel from "../views/rooms/AuxPanel";
import RoomHeader from "../views/rooms/RoomHeader";
import RoomHeader from "../views/rooms/RoomHeader/RoomHeader";
import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore";
import EffectsOverlay from "../views/elements/EffectsOverlay";
import { containsEmoji } from "../../effects/utils";
+5 -6
View File
@@ -129,10 +129,10 @@ export const ThreadPanelHeader: React.FC<{
);
return (
<div className="mx_BaseCard_header_title">
<div className="mx_ThreadPanelHeader">
<Tooltip label={_t("threads|mark_all_read")}>
<IconButton onClick={onMarkAllThreadsReadClick} size="24px">
<MarkAllThreadsReadIcon />
<IconButton onClick={onMarkAllThreadsReadClick} size="28px">
<MarkAllThreadsReadIcon height={20} width={20} />
</IconButton>
</Tooltip>
<div className="mx_ThreadPanel_vertical_separator" />
@@ -192,9 +192,7 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
narrow={narrow}
>
<BaseCard
header={
hasThreads && <ThreadPanelHeader filterOption={filterOption} setFilterOption={setFilterOption} />
}
header={_t("common|threads")}
id="thread-panel"
className="mx_ThreadPanel"
ariaLabelledBy="thread-panel-tab"
@@ -204,6 +202,7 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
ref={card}
closeButtonRef={closeButonRef}
>
{hasThreads && <ThreadPanelHeader filterOption={filterOption} setFilterOption={setFilterOption} />}
<Measured sensor={card} onMeasurement={setNarrow} />
{timelineSet ? (
<TimelinePanel
@@ -11,7 +11,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import ResizeNotifier from "../../utils/ResizeNotifier";
import ErrorBoundary from "../views/elements/ErrorBoundary";
import RoomHeader from "../views/rooms/RoomHeader";
import RoomHeader from "../views/rooms/RoomHeader/RoomHeader.tsx";
import ScrollPanel from "./ScrollPanel";
import EventTileBubble from "../views/messages/EventTileBubble";
import NewRoomIntro from "../views/rooms/NewRoomIntro";
+7 -7
View File
@@ -7,14 +7,14 @@ Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import React, { useMemo } from "react";
import React, { ComponentProps, JSXElementConstructor, useMemo } from "react";
type FlexProps = {
type FlexProps<T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>> = {
/**
* The type of the HTML element
* @default div
*/
as?: string;
as?: T;
/**
* The CSS class name.
*/
@@ -30,7 +30,7 @@ type FlexProps = {
*/
direction?: "row" | "column" | "row-reverse" | "column-reverse";
/**
* The alingment of the flex children
* The alignment of the flex children
* @default start
*/
align?: "start" | "center" | "end" | "baseline" | "stretch";
@@ -48,12 +48,12 @@ type FlexProps = {
* the on click event callback
*/
onClick?: (e: React.MouseEvent) => void;
};
} & ComponentProps<T>;
/**
* A flexbox container helper
*/
export function Flex({
export function Flex<T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any> = "div">({
as = "div",
display = "flex",
direction = "row",
@@ -63,7 +63,7 @@ export function Flex({
className,
children,
...props
}: React.PropsWithChildren<FlexProps>): JSX.Element {
}: React.PropsWithChildren<FlexProps<T>>): JSX.Element {
const style = useMemo(
() => ({
"--mx-flex-display": display,
@@ -145,7 +145,7 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT
userLabel = _t(PowerLabel[powerStatus]);
}
if (props.member.isInvite) {
userLabel = `(${_t("member_list|invited_label")})`;
userLabel = _t("member_list|invited_label");
}
return {
@@ -30,7 +30,7 @@ export function useThreePidTileViewModel(props: ThreePidTileViewModelProps): Thr
});
};
const userLabel = `(${_t("member_list|invited_label")})`;
const userLabel = _t("member_list|invited_label");
return {
name,
@@ -0,0 +1,192 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { EventType, MatrixEvent, Room, RoomMember, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import { CryptoApi, CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import { throttle } from "lodash";
import { logger } from "matrix-js-sdk/src/logger";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter.ts";
export type ViolationType = "PinViolation" | "VerificationViolation";
/**
* Represents a prompt to the user about a violation in the room.
* The type of violation and the member it relates to are included.
* If the type is "VerificationViolation", the warning is critical and should be reported with more urgency.
*/
export type ViolationPrompt = {
member: RoomMember;
type: ViolationType;
};
/**
* The state of the UserIdentityWarningViewModel.
* This includes the current prompt to show to the user and a callback to handle button clicks.
* If currentPrompt is undefined, there are no violations to show.
*/
export interface UserIdentityWarningState {
currentPrompt?: ViolationPrompt;
dispatchAction: (action: UserIdentityWarningViewModelAction) => void;
}
/**
* List of actions that can be dispatched to the UserIdentityWarningViewModel.
*/
export type UserIdentityWarningViewModelAction =
| { type: "PinUserIdentity"; userId: string }
| { type: "WithdrawVerification"; userId: string };
/**
* Maps a list of room members to a list of violations.
* Checks for all members in the room to see if they have any violations.
* If no violations are found, an empty list is returned.
*
* @param cryptoApi
* @param members - The list of room members to check for violations.
*/
async function mapToViolations(cryptoApi: CryptoApi, members: RoomMember[]): Promise<ViolationPrompt[]> {
const violationList = new Array<ViolationPrompt>();
for (const member of members) {
const verificationStatus = await cryptoApi.getUserVerificationStatus(member.userId);
if (verificationStatus.wasCrossSigningVerified() && !verificationStatus.isCrossSigningVerified()) {
violationList.push({ member, type: "VerificationViolation" });
} else if (verificationStatus.needsUserApproval) {
violationList.push({ member, type: "PinViolation" });
}
}
return violationList;
}
export function useUserIdentityWarningViewModel(room: Room, key: string): UserIdentityWarningState {
const cli = useMatrixClientContext();
const crypto = cli.getCrypto();
const [members, setMembers] = useState<RoomMember[]>([]);
const [currentPrompt, setCurrentPrompt] = useState<ViolationPrompt | undefined>(undefined);
const loadViolations = useMemo(
() =>
throttle(async (): Promise<void> => {
const isEncrypted = crypto && (await crypto.isEncryptionEnabledInRoom(room.roomId));
if (!isEncrypted) {
setMembers([]);
setCurrentPrompt(undefined);
return;
}
const targetMembers = await room.getEncryptionTargetMembers();
setMembers(targetMembers);
const violations = await mapToViolations(crypto, targetMembers);
let candidatePrompt: ViolationPrompt | undefined;
if (violations.length > 0) {
// sort by user ID to ensure consistent ordering
const sortedViolations = violations.sort((a, b) => a.member.userId.localeCompare(b.member.userId));
candidatePrompt = sortedViolations[0];
} else {
candidatePrompt = undefined;
}
// is the current prompt still valid?
setCurrentPrompt((existingPrompt): ViolationPrompt | undefined => {
if (existingPrompt && violations.includes(existingPrompt)) {
return existingPrompt;
} else if (candidatePrompt) {
return candidatePrompt;
} else {
return undefined;
}
});
}),
[crypto, room],
);
// We need to listen for changes to the members list
useTypedEventEmitter(
cli,
RoomStateEvent.Events,
useCallback(
async (event: MatrixEvent): Promise<void> => {
if (!crypto || event.getRoomId() !== room.roomId) {
return;
}
let shouldRefresh = false;
const eventType = event.getType();
if (eventType === EventType.RoomEncryption && event.getStateKey() === "") {
// Room is now encrypted, so we can initialise the component.
shouldRefresh = true;
} else if (eventType == EventType.RoomMember) {
// We're processing an m.room.member event
// Something has changed in membership, someone joined or someone left or
// someone changed their display name. Anyhow let's refresh.
const userId = event.getStateKey();
shouldRefresh = !!userId;
}
if (shouldRefresh) {
loadViolations().catch((e) => {
logger.error("Error refreshing UserIdentityWarningViewModel:", e);
});
}
},
[crypto, room, loadViolations],
),
);
// We need to listen for changes to the verification status of the members to refresh violations
useTypedEventEmitter(
cli,
CryptoEvent.UserTrustStatusChanged,
useCallback(
(userId: string): void => {
if (members.find((m) => m.userId == userId)) {
// This member is tracked, we need to refresh.
// refresh all for now?
// As a later optimisation we could store the current violations and only update the relevant one.
loadViolations().catch((e) => {
logger.error("Error refreshing UserIdentityWarning:", e);
});
}
},
[loadViolations, members],
),
);
useEffect(() => {
loadViolations().catch((e) => {
logger.error("Error initialising UserIdentityWarning:", e);
});
}, [loadViolations]);
const dispatchAction = useCallback(
(action: UserIdentityWarningViewModelAction): void => {
if (!crypto) {
return;
}
if (action.type === "PinUserIdentity") {
crypto.pinCurrentUserIdentity(action.userId).catch((e) => {
logger.error("Error pinning user identity:", e);
});
} else if (action.type === "WithdrawVerification") {
crypto.withdrawVerificationRequirement(action.userId).catch((e) => {
logger.error("Error withdrawing verification requirement:", e);
});
}
},
[crypto],
);
return {
currentPrompt,
dispatchAction,
};
}
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { InternationalisedPolicy, Terms, MatrixClient } from "matrix-js-sdk/src/matrix";
import { AuthType, AuthDict, IInputs, IStageStatus } from "matrix-js-sdk/src/interactive-auth";
import { logger } from "matrix-js-sdk/src/logger";
import React, { ChangeEvent, createRef, FormEvent, Fragment } from "react";
@@ -16,14 +16,13 @@ import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-o
import EmailPromptIcon from "../../../../res/img/element-icons/email-prompt.svg";
import { _t } from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import { LocalisedPolicy, Policies } from "../../../Terms";
import { AuthHeaderModifier } from "../../structures/auth/header/AuthHeaderModifier";
import AccessibleButton, { AccessibleButtonKind, ButtonEvent } from "../elements/AccessibleButton";
import Field from "../elements/Field";
import Spinner from "../elements/Spinner";
import CaptchaForm from "./CaptchaForm";
import { Flex } from "../../utils/Flex";
import { pickBestPolicyLanguage } from "../../../Terms.ts";
/* This file contains a collection of components which are used by the
* InteractiveAuth to prompt the user to enter the information needed
@@ -235,12 +234,10 @@ export class RecaptchaAuthEntry extends React.Component<IRecaptchaAuthEntryProps
}
interface ITermsAuthEntryProps extends IAuthEntryProps {
stageParams?: {
policies?: Policies;
};
stageParams?: Partial<Terms>;
}
interface LocalisedPolicyWithId extends LocalisedPolicy {
interface LocalisedPolicyWithId extends InternationalisedPolicy {
id: string;
}
@@ -278,7 +275,6 @@ export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITerms
// }
const allPolicies = this.props.stageParams?.policies || {};
const prefLang = SettingsStore.getValue("language");
const initToggles: Record<string, boolean> = {};
const pickedPolicies: {
id: string;
@@ -287,17 +283,7 @@ export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITerms
}[] = [];
for (const policyId of Object.keys(allPolicies)) {
const policy = allPolicies[policyId];
// Pick a language based on the user's language, falling back to english,
// and finally to the first language available. If there's still no policy
// available then the homeserver isn't respecting the spec.
let langPolicy: LocalisedPolicy | undefined = policy[prefLang];
if (!langPolicy) langPolicy = policy["en"];
if (!langPolicy) {
// last resort
const firstLang = Object.keys(policy).find((e) => e !== "version");
langPolicy = firstLang ? policy[firstLang] : undefined;
}
const langPolicy = pickBestPolicyLanguage(policy);
if (!langPolicy) throw new Error("Failed to find a policy to show the user");
initToggles[policyId] = false;
@@ -24,6 +24,7 @@ import { SettingLevel } from "../../../settings/SettingLevel";
import ServerInfo from "./devtools/ServerInfo";
import CopyableText from "../elements/CopyableText";
import RoomNotifications from "./devtools/RoomNotifications";
import { Crypto } from "./devtools/Crypto";
enum Category {
Room,
@@ -49,6 +50,7 @@ const Tools: Record<Category, [label: TranslationKey, tool: Tool][]> = {
[_td("devtools|explore_account_data"), AccountDataExplorer],
[_td("devtools|settings_explorer"), SettingExplorer],
[_td("devtools|server_info"), ServerInfo],
[_td("devtools|crypto|title"), Crypto],
],
};
@@ -33,7 +33,7 @@ import { OwnProfileStore } from "../../../stores/OwnProfileStore";
import { arrayFastClone } from "../../../utils/arrays";
import { ElementWidget } from "../../../stores/widgets/StopGapWidget";
import { ELEMENT_CLIENT_ID } from "../../../identifiers";
import SettingsStore from "../../../settings/SettingsStore";
import ThemeWatcher, { ThemeWatcherEvent } from "../../../settings/watchers/ThemeWatcher";
interface IProps {
widgetDefinition: IModalWidgetOpenRequestData;
@@ -54,6 +54,7 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
private readonly widget: Widget;
private readonly possibleButtons: ModalButtonID[];
private appFrame: React.RefObject<HTMLIFrameElement> = React.createRef();
private readonly themeWatcher = new ThemeWatcher();
public state: IState = {
disabledButtonIds: (this.props.widgetDefinition.buttons || []).filter((b) => b.disabled).map((b) => b.id),
@@ -77,6 +78,8 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
}
public componentWillUnmount(): void {
this.themeWatcher.off(ThemeWatcherEvent.Change, this.onThemeChange);
this.themeWatcher.stop();
if (!this.state.messaging) return;
this.state.messaging.off("ready", this.onReady);
this.state.messaging.off(`action:${WidgetApiFromWidgetAction.CloseModalWidget}`, this.onWidgetClose);
@@ -84,6 +87,10 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
}
private onReady = (): void => {
this.themeWatcher.start();
this.themeWatcher.on(ThemeWatcherEvent.Change, this.onThemeChange);
// Theme may have changed while messaging was starting
this.onThemeChange(this.themeWatcher.getEffectiveTheme());
this.state.messaging?.sendWidgetConfig(this.props.widgetDefinition);
};
@@ -94,6 +101,10 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
this.state.messaging.on(`action:${WidgetApiFromWidgetAction.SetModalButtonEnabled}`, this.onButtonEnableToggle);
};
private onThemeChange = (theme: string): void => {
this.state.messaging?.updateTheme({ name: theme });
};
private onWidgetClose = (ev: CustomEvent<IModalWidgetCloseRequest>): void => {
this.props.onFinished(true, ev.detail.data);
};
@@ -127,7 +138,7 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
userDisplayName: OwnProfileStore.instance.displayName ?? undefined,
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl() ?? undefined,
clientId: ELEMENT_CLIENT_ID,
clientTheme: SettingsStore.getValue("theme"),
clientTheme: this.themeWatcher.getEffectiveTheme(),
clientLanguage: getUserLanguage(),
baseUrl: MatrixClientPeg.safeGet().baseUrl,
});
+10 -10
View File
@@ -9,10 +9,10 @@ Please see LICENSE files in the repository root for full details.
import React from "react";
import { SERVICE_TYPES } from "matrix-js-sdk/src/matrix";
import { _t, pickBestLanguage } from "../../../languageHandler";
import { _t } from "../../../languageHandler";
import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "./BaseDialog";
import { ServicePolicyPair } from "../../../Terms";
import { pickBestPolicyLanguage, ServicePolicyPair } from "../../../Terms";
import ExternalLink from "../elements/ExternalLink";
import { parseUrl } from "../../../utils/UrlUtils";
@@ -126,8 +126,8 @@ export default class TermsDialog extends React.PureComponent<ITermsDialogProps,
const policyValues = Object.values(policiesAndService.policies);
for (let i = 0; i < policyValues.length; ++i) {
const termDoc = policyValues[i];
const termsLang = pickBestLanguage(Object.keys(termDoc).filter((k) => k !== "version"));
const internationalisedPolicy = pickBestPolicyLanguage(policyValues[i]);
if (!internationalisedPolicy) continue;
let serviceName: JSX.Element | undefined;
let summary: JSX.Element | undefined;
if (i === 0) {
@@ -136,19 +136,19 @@ export default class TermsDialog extends React.PureComponent<ITermsDialogProps,
}
rows.push(
<tr key={termDoc[termsLang].url}>
<tr key={internationalisedPolicy.url}>
<td className="mx_TermsDialog_service">{serviceName}</td>
<td className="mx_TermsDialog_summary">{summary}</td>
<td>
<ExternalLink rel="noreferrer noopener" target="_blank" href={termDoc[termsLang].url}>
{termDoc[termsLang].name}
<ExternalLink rel="noreferrer noopener" target="_blank" href={internationalisedPolicy.url}>
{internationalisedPolicy.name}
</ExternalLink>
</td>
<td>
<TermsCheckbox
url={termDoc[termsLang].url}
url={internationalisedPolicy.url}
onChange={this.onTermsCheckboxChange}
checked={Boolean(this.state.agreedUrls[termDoc[termsLang].url])}
checked={Boolean(this.state.agreedUrls[internationalisedPolicy.url])}
/>
</td>
</tr>,
@@ -164,7 +164,7 @@ export default class TermsDialog extends React.PureComponent<ITermsDialogProps,
for (const terms of Object.values(policiesAndService.policies)) {
let docAgreed = false;
for (const lang of Object.keys(terms)) {
if (lang === "version") continue;
if (lang === "version" || typeof terms[lang] === "string") continue;
if (this.state.agreedUrls[terms[lang].url]) {
docAgreed = true;
break;
@@ -50,6 +50,7 @@ import { EncryptionUserSettingsTab } from "../settings/tabs/user/EncryptionUserS
interface IProps {
initialTabId?: UserTab;
showMsc4108QrCode?: boolean;
showResetIdentity?: boolean;
sdkContext: SdkContextClass;
onFinished(): void;
}
@@ -91,8 +92,9 @@ function titleForTabID(tabId: UserTab): React.ReactNode {
export default function UserSettingsDialog(props: IProps): JSX.Element {
const voipEnabled = useSettingValue(UIFeature.Voip);
const mjolnirEnabled = useSettingValue("feature_mjolnir");
// store this prop in state as changing tabs back and forth should clear it
// store these props in state as changing tabs back and forth should clear it
const [showMsc4108QrCode, setShowMsc4108QrCode] = useState(props.showMsc4108QrCode);
const [showResetIdentity, setShowResetIdentity] = useState(props.showResetIdentity);
const getTabs = (): NonEmptyArray<Tab<UserTab>> => {
const tabs: Tab<UserTab>[] = [];
@@ -184,7 +186,12 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
);
tabs.push(
new Tab(UserTab.Encryption, _td("settings|encryption|title"), <KeyIcon />, <EncryptionUserSettingsTab />),
new Tab(
UserTab.Encryption,
_td("settings|encryption|title"),
<KeyIcon />,
<EncryptionUserSettingsTab initialState={showResetIdentity ? "reset_identity_forgot" : undefined} />,
),
);
if (showLabsFlags() || SettingsStore.getFeatureSettingNames().some((k) => SettingsStore.getBetaInfo(k))) {
@@ -219,8 +226,9 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
const [activeTabId, _setActiveTabId] = useActiveTabWithDefault(getTabs(), UserTab.Account, props.initialTabId);
const setActiveTabId = (tabId: UserTab): void => {
_setActiveTabId(tabId);
// Clear this so switching away from the tab and back to it will not show the QR code again
// Clear these so switching away from the tab and back to it will not show the QR code again
setShowMsc4108QrCode(false);
setShowResetIdentity(false);
};
const [activeToast, toastRack] = useActiveToast();
@@ -0,0 +1,256 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { JSX } from "react";
import { InlineSpinner } from "@vector-im/compound-web";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import BaseTool from "./BaseTool";
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
import { _t } from "../../../../languageHandler";
interface KeyBackupProps {
/**
* Callback to invoke when the back button is clicked.
*/
onBack(): void;
}
/**
* A component that displays information about the key storage and cross-signing.
*/
export function Crypto({ onBack }: KeyBackupProps): JSX.Element {
const matrixClient = useMatrixClientContext();
return (
<BaseTool onBack={onBack} className="mx_Crypto">
{matrixClient.getCrypto() ? (
<>
<KeyStorage />
<CrossSigning />
</>
) : (
<span>{_t("devtools|crypto|crypto_not_available")}</span>
)}
</BaseTool>
);
}
/**
* A component that displays information about the key storage.
*/
function KeyStorage(): JSX.Element {
const matrixClient = useMatrixClientContext();
const keyStorageData = useAsyncMemo(async () => {
const crypto = matrixClient.getCrypto()!;
// Get all the key storage data that we will display
const backupInfo = await crypto.getKeyBackupInfo();
const backupKeyStored = Boolean(await matrixClient.isKeyBackupKeyStored());
const backupKeyFromCache = await crypto.getSessionBackupPrivateKey();
const backupKeyCached = Boolean(backupKeyFromCache);
const backupKeyWellFormed = backupKeyFromCache instanceof Uint8Array;
const activeBackupVersion = await crypto.getActiveSessionBackupVersion();
const secretStorageKeyInAccount = await matrixClient.secretStorage.hasKey();
const secretStorageReady = await crypto.isSecretStorageReady();
return {
backupInfo,
backupKeyStored,
backupKeyCached,
backupKeyWellFormed,
activeBackupVersion,
secretStorageKeyInAccount,
secretStorageReady,
};
}, [matrixClient]);
// Show a spinner while loading
if (keyStorageData === undefined) return <InlineSpinner aria-label={_t("common|loading")} />;
const {
backupInfo,
backupKeyStored,
backupKeyCached,
backupKeyWellFormed,
activeBackupVersion,
secretStorageKeyInAccount,
secretStorageReady,
} = keyStorageData;
return (
<table aria-label={_t("devtools|crypto|key_storage")}>
<thead>{_t("devtools|crypto|key_storage")}</thead>
<tbody>
<tr>
<th scope="row">{_t("devtools|crypto|key_backup_latest_version")}</th>
<td>
{backupInfo
? `${backupInfo.version} (${_t("settings|security|key_backup_algorithm")} ${backupInfo.algorithm})`
: _t("devtools|crypto|key_backup_inactive_warning")}
</td>
</tr>
<tr>
<th scope="row">{_t("devtools|crypto|backup_key_stored_status")}</th>
<td>
{backupKeyStored
? _t("devtools|crypto|backup_key_stored")
: _t("devtools|crypto|backup_key_not_stored")}
</td>
</tr>
<tr>
<th scope="row">{_t("devtools|crypto|key_backup_active_version")}</th>
<td>
{activeBackupVersion === null
? _t("devtools|crypto|key_backup_active_version_none")
: activeBackupVersion}
</td>
</tr>
<tr>
<th scope="row">{_t("devtools|crypto|backup_key_cached_status")}</th>
<td>
{`${
backupKeyCached
? _t("devtools|crypto|backup_key_cached")
: _t("devtools|crypto|not_found_locally")
}, ${
backupKeyWellFormed
? _t("devtools|crypto|backup_key_well_formed")
: _t("devtools|crypto|backup_key_unexpected_type")
}`}
</td>
</tr>
<tr>
<th scope="row">{_t("devtools|crypto|4s_public_key_status")}</th>
<td>
{secretStorageKeyInAccount
? _t("devtools|crypto|4s_public_key_in_account_data")
: _t("devtools|crypto|4s_public_key_not_in_account_data")}
</td>
</tr>
<tr>
<th scope="row">{_t("devtools|crypto|secret_storage_status")}</th>
<td>
{secretStorageReady
? _t("devtools|crypto|secret_storage_ready")
: _t("devtools|crypto|secret_storage_not_ready")}
</td>
</tr>
</tbody>
</table>
);
}
/**
* A component that displays information about cross-signing.
*/
function CrossSigning(): JSX.Element {
const matrixClient = useMatrixClientContext();
const crossSigningData = useAsyncMemo(async () => {
const crypto = matrixClient.getCrypto()!;
// Get all the cross-signing data that we will display
const crossSigningStatus = await crypto.getCrossSigningStatus();
const crossSigningPublicKeysOnDevice = crossSigningStatus.publicKeysOnDevice;
const crossSigningPrivateKeysInStorage = crossSigningStatus.privateKeysInSecretStorage;
const masterPrivateKeyCached = crossSigningStatus.privateKeysCachedLocally.masterKey;
const selfSigningPrivateKeyCached = crossSigningStatus.privateKeysCachedLocally.selfSigningKey;
const userSigningPrivateKeyCached = crossSigningStatus.privateKeysCachedLocally.userSigningKey;
const crossSigningReady = await crypto.isCrossSigningReady();
return {
crossSigningPublicKeysOnDevice,
crossSigningPrivateKeysInStorage,
masterPrivateKeyCached,
selfSigningPrivateKeyCached,
userSigningPrivateKeyCached,
crossSigningReady,
};
}, [matrixClient]);
// Show a spinner while loading
if (crossSigningData === undefined) return <InlineSpinner aria-label={_t("common|loading")} />;
const {
crossSigningPublicKeysOnDevice,
crossSigningPrivateKeysInStorage,
masterPrivateKeyCached,
selfSigningPrivateKeyCached,
userSigningPrivateKeyCached,
crossSigningReady,
} = crossSigningData;
return (
<table aria-label={_t("devtools|crypto|cross_signing")}>
<thead>{_t("devtools|crypto|cross_signing")}</thead>
<tbody>
<tr>
<th scope="row">{_t("devtools|crypto|cross_signing_status")}</th>
<td>{getCrossSigningStatus(crossSigningReady, crossSigningPrivateKeysInStorage)}</td>
</tr>
<tr>
<th scope="row">{_t("devtools|crypto|cross_signing_public_keys_on_device_status")}</th>
<td>
{crossSigningPublicKeysOnDevice
? _t("devtools|crypto|cross_signing_public_keys_on_device")
: _t("devtools|crypto|not_found")}
</td>
</tr>
<tr>
<th scope="row">{_t("devtools|crypto|cross_signing_private_keys_in_storage_status")}</th>
<td>
{crossSigningPrivateKeysInStorage
? _t("devtools|crypto|cross_signing_private_keys_in_storage")
: _t("devtools|crypto|cross_signing_private_keys_not_in_storage")}
</td>
</tr>
<tr>
<th scope="row">{_t("devtools|crypto|master_private_key_cached_status")}</th>
<td>
{masterPrivateKeyCached
? _t("devtools|crypto|cross_signing_cached")
: _t("devtools|crypto|not_found_locally")}
</td>
</tr>
<tr>
<th scope="row">{_t("devtools|crypto|self_signing_private_key_cached_status")}</th>
<td>
{selfSigningPrivateKeyCached
? _t("devtools|crypto|cross_signing_cached")
: _t("devtools|crypto|not_found_locally")}
</td>
</tr>
<tr>
<th scope="row">{_t("devtools|crypto|user_signing_private_key_cached_status")}</th>
<td>
{userSigningPrivateKeyCached
? _t("devtools|crypto|cross_signing_cached")
: _t("devtools|crypto|not_found_locally")}
</td>
</tr>
</tbody>
</table>
);
}
/**
* Get the cross-signing status.
* @param crossSigningReady Whether cross-signing is ready.
* @param crossSigningPrivateKeysInStorage Whether cross-signing private keys are in secret storage.
*/
function getCrossSigningStatus(crossSigningReady: boolean, crossSigningPrivateKeysInStorage: boolean): string {
if (crossSigningReady) {
return crossSigningPrivateKeysInStorage
? _t("devtools|crypto|cross_signing_ready")
: _t("devtools|crypto|cross_signing_untrusted");
}
if (crossSigningPrivateKeysInStorage) {
return _t("devtools|crypto|cross_signing_not_ready");
}
return _t("devtools|crypto|cross_signing_not_ready");
}
+10 -1
View File
@@ -9,9 +9,12 @@ Please see LICENSE files in the repository root for full details.
import React, { FC, HTMLAttributes, ReactNode } from "react";
import { RoomMember } from "matrix-js-sdk/src/matrix";
import { AvatarStack, Tooltip } from "@vector-im/compound-web";
import classNames from "classnames";
import MemberAvatar from "../avatars/MemberAvatar";
import AccessibleButton, { ButtonEvent } from "./AccessibleButton";
import { useToggled } from "../rooms/RoomHeader/toggle/useToggled";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
interface IProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
members: RoomMember[];
@@ -57,8 +60,14 @@ const FacePile: FC<IProps> = ({
</>
);
const toggled = useToggled(RightPanelPhases.MemberList);
const classes = classNames({
mx_FacePile: true,
mx_FacePile_toggled: toggled,
});
const content = (
<AccessibleButton {...props} className="mx_FacePile" onClick={onClick ?? null}>
<AccessibleButton {...props} className={classes} onClick={onClick ?? null}>
<AvatarStack>{pileContents}</AvatarStack>
{children}
</AccessibleButton>
+24 -2
View File
@@ -25,6 +25,7 @@ export enum PillType {
AtRoomMention = "TYPE_AT_ROOM_MENTION", // '@room' mention
EventInSameRoom = "TYPE_EVENT_IN_SAME_ROOM",
EventInOtherRoom = "TYPE_EVENT_IN_OTHER_ROOM",
Keyword = "TYPE_KEYWORD", // Used to highlight keywords that triggered a notification rule
}
export const pillRoomNotifPos = (text: string | null): number => {
@@ -76,14 +77,32 @@ export interface PillProps {
room?: Room;
// Whether to include an avatar in the pill
shouldShowPillAvatar?: boolean;
// Explicitly-provided text to display in the pill
text?: string;
}
export const Pill: React.FC<PillProps> = ({ type: propType, url, inMessage, room, shouldShowPillAvatar = true }) => {
const { event, member, onClick, resourceId, targetRoom, text, type } = usePermalink({
export const Pill: React.FC<PillProps> = ({
type: propType,
url,
inMessage,
room,
shouldShowPillAvatar = true,
text: customPillText,
}) => {
const {
event,
member,
onClick,
resourceId,
targetRoom,
text: linkText,
type,
} = usePermalink({
room,
type: propType,
url,
});
const text = customPillText ?? linkText;
if (!type || !text) {
return null;
@@ -96,6 +115,7 @@ export const Pill: React.FC<PillProps> = ({ type: propType, url, inMessage, room
mx_UserPill: type === PillType.UserMention,
mx_UserPill_me: resourceId === MatrixClientPeg.safeGet().getUserId(),
mx_EventPill: type === PillType.EventInOtherRoom || type === PillType.EventInSameRoom,
mx_KeywordPill: type === PillType.Keyword,
});
let avatar: ReactElement | null = null;
@@ -131,6 +151,8 @@ export const Pill: React.FC<PillProps> = ({ type: propType, url, inMessage, room
case PillType.UserMention:
avatar = <PillMemberAvatar shouldShowPillAvatar={shouldShowPillAvatar} member={member} />;
break;
case PillType.Keyword:
break;
default:
return null;
}
+62 -1
View File
@@ -7,8 +7,9 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { createRef, SyntheticEvent, MouseEvent, StrictMode } from "react";
import { MsgType } from "matrix-js-sdk/src/matrix";
import { MsgType, PushRuleKind } from "matrix-js-sdk/src/matrix";
import { TooltipProvider } from "@vector-im/compound-web";
import { globToRegexp } from "matrix-js-sdk/src/utils";
import * as HtmlUtils from "../../../HtmlUtils";
import { formatDate } from "../../../DateUtils";
@@ -35,6 +36,7 @@ import { EditWysiwygComposer } from "../rooms/wysiwyg_composer";
import { IEventTileOps } from "../rooms/EventTile";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import CodeBlock from "./CodeBlock";
import { Pill, PillType } from "../elements/Pill";
import { ReactRootManager } from "../../../utils/react";
interface IState {
@@ -100,6 +102,16 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
}
}
}
// Highlight notification keywords using pills
const pushDetails = this.props.mxEvent.getPushDetails();
if (
pushDetails.rule?.enabled &&
pushDetails.rule.kind === PushRuleKind.ContentSpecific &&
pushDetails.rule.pattern
) {
this.pillifyNotificationKeywords([content], this.regExpForKeywordPattern(pushDetails.rule.pattern));
}
}
private addCodeElement(pre: HTMLPreElement): void {
@@ -210,6 +222,55 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
}
}
/**
* Marks the text that activated a push-notification keyword pattern.
*/
private pillifyNotificationKeywords(nodes: ArrayLike<Element>, exp: RegExp): void {
let node: Node | null = nodes[0];
while (node) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.nodeValue;
if (!text) {
node = node.nextSibling;
continue;
}
const match = text.match(exp);
if (!match || match.length < 3) {
node = node.nextSibling;
continue;
}
const keywordText = match[2];
const idx = match.index! + match[1].length;
const before = text.substring(0, idx);
const after = text.substring(idx + keywordText.length);
const container = document.createElement("span");
const newContent = (
<>
{before}
<TooltipProvider>
<Pill text={keywordText} type={PillType.Keyword} />
</TooltipProvider>
{after}
</>
);
this.reactRoots.render(newContent, container, node);
node.parentNode?.replaceChild(container, node);
} else if (node.childNodes && node.childNodes.length) {
this.pillifyNotificationKeywords(node.childNodes as NodeListOf<Element>, exp);
}
node = node.nextSibling;
}
}
private regExpForKeywordPattern(pattern: string): RegExp {
// Reflects the push notification pattern-matching implementation at
// https://github.com/matrix-org/matrix-js-sdk/blob/dbd7d26968b94700827bac525c39afff2c198e61/src/pushprocessor.ts#L570
return new RegExp("(^|\\W)(" + globToRegexp(pattern) + ")(\\W|$)", "i");
}
private findLinks(nodes: ArrayLike<Element>): string[] {
let links: string[] = [];
@@ -85,6 +85,7 @@ import { asyncSome } from "../../../utils/arrays";
import { Flex } from "../../utils/Flex";
import CopyableText from "../elements/CopyableText";
import { useUserTimezone } from "../../../hooks/useUserTimezone";
export interface IDevice extends Device {
ambiguous?: boolean;
}
@@ -580,8 +581,10 @@ export const warnSelfDemote = async (isSpace: boolean): Promise<boolean> => {
const Container: React.FC<{
children: ReactNode;
}> = ({ children }) => {
return <div className="mx_UserInfo_container">{children}</div>;
className?: string;
}> = ({ children, className }) => {
const classes = classNames("mx_UserInfo_container", className);
return <div className={classes}>{children}</div>;
};
interface IPowerLevelsContent {
@@ -1707,10 +1710,10 @@ export const UserInfoHeader: React.FC<{
</div>
</div>
<Container>
<Container className="mx_UserInfo_header">
<Flex direction="column" align="center" className="mx_UserInfo_profile">
<Heading size="sm" weight="semibold" as="h1" dir="auto">
<Flex direction="row-reverse" align="center">
<Flex className="mx_UserInfo_profile_name" direction="row-reverse" align="center">
{displayName}
{e2eIcon}
</Flex>
@@ -1718,11 +1721,11 @@ export const UserInfoHeader: React.FC<{
{presenceLabel}
{timezoneInfo && (
<Tooltip label={timezoneInfo?.timezone ?? ""}>
<span className="mx_UserInfo_timezone">
<Flex align="center" className="mx_UserInfo_timezone">
<Text size="sm" weight="regular">
{timezoneInfo?.friendly ?? ""}
</Text>
</span>
</Flex>
</Tooltip>
)}
<Text size="sm" weight="semibold" className="mx_UserInfo_profile_mxid">
@@ -21,6 +21,7 @@ import { ThreePidInviteTileView } from "./tiles/ThreePidInviteTileView";
import { MemberListHeaderView } from "./MemberListHeaderView";
import BaseCard from "../../right_panel/BaseCard";
import { _t } from "../../../../languageHandler";
import { RovingTabIndexProvider } from "../../../../accessibility/RovingTabIndex";
interface IProps {
roomId: string;
@@ -86,24 +87,33 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
header={_t("common|people")}
onClose={props.onClose}
>
<Flex align="stretch" direction="column" className="mx_MemberListView_container">
<Form.Root>
<MemberListHeaderView vm={vm} />
</Form.Root>
<AutoSizer>
{({ height, width }) => (
<List
rowRenderer={rowRenderer}
rowHeight={getRowHeight}
// The +1 refers to the additional empty div that we render at the end of the list.
rowCount={totalRows + 1}
// Subtract the height of MemberlistHeaderView so that the parent div does not overflow.
height={height - 113}
width={width}
/>
)}
</AutoSizer>
</Flex>
<RovingTabIndexProvider handleUpDown scrollIntoView>
{({ onKeyDownHandler }) => (
<Flex
align="stretch"
direction="column"
className="mx_MemberListView_container"
onKeyDown={onKeyDownHandler}
>
<Form.Root>
<MemberListHeaderView vm={vm} />
</Form.Root>
<AutoSizer>
{({ height, width }) => (
<List
rowRenderer={rowRenderer}
rowHeight={getRowHeight}
// The +1 refers to the additional empty div that we render at the end of the list.
rowCount={totalRows + 1}
// Subtract the height of MemberlistHeaderView so that the parent div does not overflow.
height={height - 113}
width={width}
/>
)}
</AutoSizer>
</Flex>
)}
</RovingTabIndexProvider>
</BaseCard>
);
};
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
import React from "react";
import AccessibleButton from "../../../../elements/AccessibleButton";
import { RovingAccessibleButton } from "../../../../../../accessibility/RovingTabIndex";
interface Props {
avatarJsx: JSX.Element;
@@ -28,7 +28,7 @@ export function MemberTileView(props: Props): JSX.Element {
return (
// The wrapping div is required to make the magic mouse listener work, for some reason.
<div>
<AccessibleButton className="mx_MemberTileView" title={props.title} onClick={props.onClick}>
<RovingAccessibleButton className="mx_MemberTileView" title={props.title} onClick={props.onClick}>
<div className="mx_MemberTileView_left">
<div className="mx_MemberTileView_avatar">
{props.avatarJsx} {props.presenceJsx}
@@ -39,7 +39,7 @@ export function MemberTileView(props: Props): JSX.Element {
{userLabelJsx}
{props.iconJsx}
</div>
</AccessibleButton>
</RovingAccessibleButton>
</div>
);
}
-408
View File
@@ -1,408 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { useCallback, useMemo, useState } from "react";
import { Body as BodyText, Button, IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web";
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
import VoiceCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/voice-call";
import CloseCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
import ThreadsIcon from "@vector-im/compound-design-tokens/assets/web/icons/threads-solid";
import RoomInfoIcon from "@vector-im/compound-design-tokens/assets/web/icons/info-solid";
import NotificationsIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-solid";
import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error";
import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public";
import { JoinRule, type Room } from "matrix-js-sdk/src/matrix";
import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
import { useRoomName } from "../../../hooks/useRoomName";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useRoomMemberCount, useRoomMembers } from "../../../hooks/useRoomMembers";
import { _t } from "../../../languageHandler";
import { Flex } from "../../utils/Flex";
import { Box } from "../../utils/Box";
import { getPlatformCallTypeProps, useRoomCall } from "../../../hooks/room/useRoomCall";
import { useRoomThreadNotifications } from "../../../hooks/room/useRoomThreadNotifications";
import { useGlobalNotificationState } from "../../../hooks/useGlobalNotificationState";
import SdkConfig from "../../../SdkConfig";
import { useFeatureEnabled } from "../../../hooks/useSettings";
import { useEncryptionStatus } from "../../../hooks/useEncryptionStatus";
import { E2EStatus } from "../../../utils/ShieldUtils";
import FacePile from "../elements/FacePile";
import { useRoomState } from "../../../hooks/useRoomState";
import RoomAvatar from "../avatars/RoomAvatar";
import { formatCount } from "../../../utils/FormattingUtils";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import PosthogTrackers from "../../../PosthogTrackers";
import { VideoRoomChatButton } from "./RoomHeader/VideoRoomChatButton";
import { RoomKnocksBar } from "./RoomKnocksBar";
import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms";
import { notificationLevelToIndicator } from "../../../utils/notifications";
import { CallGuestLinkButton } from "./RoomHeader/CallGuestLinkButton";
import { ButtonEvent } from "../elements/AccessibleButton";
import WithPresenceIndicator, { useDmMember } from "../avatars/WithPresenceIndicator";
import { IOOBData } from "../../../stores/ThreepidInviteStore";
import { MainSplitContentType } from "../../structures/RoomView";
import defaultDispatcher from "../../../dispatcher/dispatcher.ts";
import { RoomSettingsTab } from "../dialogs/RoomSettingsDialog.tsx";
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
export default function RoomHeader({
room,
additionalButtons,
oobData,
}: {
room: Room;
additionalButtons?: ViewRoomOpts["buttons"];
oobData?: IOOBData;
}): JSX.Element {
const client = useMatrixClientContext();
const roomName = useRoomName(room);
const joinRule = useRoomState(room, (state) => state.getJoinRule());
const members = useRoomMembers(room, 2500);
const memberCount = useRoomMemberCount(room, { throttleWait: 2500 });
const {
voiceCallDisabledReason,
voiceCallClick,
videoCallDisabledReason,
videoCallClick,
toggleCallMaximized: toggleCall,
isViewingCall,
isConnectedToCall,
hasActiveCallSession,
callOptions,
showVoiceCallButton,
showVideoCallButton,
} = useRoomCall(room);
const groupCallsEnabled = useFeatureEnabled("feature_group_calls");
/**
* A special mode where only Element Call is used. In this case we want to
* hide the voice call button
*/
const useElementCallExclusively = useMemo(() => {
return SdkConfig.get("element_call").use_exclusively && groupCallsEnabled;
}, [groupCallsEnabled]);
const threadNotifications = useRoomThreadNotifications(room);
const globalNotificationState = useGlobalNotificationState();
const dmMember = useDmMember(room);
const isDirectMessage = !!dmMember;
const e2eStatus = useEncryptionStatus(client, room);
const notificationsEnabled = useFeatureEnabled("feature_notifications");
const askToJoinEnabled = useFeatureEnabled("feature_ask_to_join");
const videoClick = useCallback(
(ev: React.MouseEvent) => videoCallClick(ev, callOptions[0]),
[callOptions, videoCallClick],
);
const toggleCallButton = (
<Tooltip label={isViewingCall ? _t("voip|minimise_call") : _t("voip|maximise_call")}>
<IconButton onClick={toggleCall}>
<VideoCallIcon />
</IconButton>
</Tooltip>
);
const joinCallButton = (
<Tooltip label={videoCallDisabledReason ?? _t("voip|video_call")}>
<Button
size="sm"
onClick={videoClick}
Icon={VideoCallIcon}
className="mx_RoomHeader_join_button"
disabled={!!videoCallDisabledReason}
color="primary"
aria-label={videoCallDisabledReason ?? _t("action|join")}
>
{_t("action|join")}
</Button>
</Tooltip>
);
const callIconWithTooltip = (
<Tooltip label={videoCallDisabledReason ?? _t("voip|video_call")}>
<VideoCallIcon />
</Tooltip>
);
const [menuOpen, setMenuOpen] = useState(false);
const onOpenChange = useCallback(
(newOpen: boolean) => {
if (!videoCallDisabledReason) setMenuOpen(newOpen);
},
[videoCallDisabledReason],
);
const startVideoCallButton = (
<>
{/* Can be either a menu or just a button depending on the number of call options.*/}
{callOptions.length > 1 ? (
<Menu
open={menuOpen}
onOpenChange={onOpenChange}
title={_t("voip|video_call_using")}
trigger={
<IconButton
disabled={!!videoCallDisabledReason}
aria-label={videoCallDisabledReason ?? _t("voip|video_call")}
>
{callIconWithTooltip}
</IconButton>
}
side="left"
align="start"
>
{callOptions.map((option) => {
const { label, children } = getPlatformCallTypeProps(option);
return (
<MenuItem
key={option}
label={label}
aria-label={label}
children={children}
className="mx_RoomHeader_videoCallOption"
onClick={(ev) => videoCallClick(ev, option)}
Icon={VideoCallIcon}
onSelect={() => {} /* Dummy handler since we want the click event.*/}
/>
);
})}
</Menu>
) : (
<IconButton
disabled={!!videoCallDisabledReason}
aria-label={videoCallDisabledReason ?? _t("voip|video_call")}
onClick={videoClick}
>
{callIconWithTooltip}
</IconButton>
)}
</>
);
let voiceCallButton: JSX.Element | undefined = (
<Tooltip label={voiceCallDisabledReason ?? _t("voip|voice_call")}>
<IconButton
// We need both: isViewingCall and isConnectedToCall
// - in the Lobby we are viewing a call but are not connected to it.
// - in pip view we are connected to the call but not viewing it.
disabled={!!voiceCallDisabledReason || isViewingCall || isConnectedToCall}
aria-label={voiceCallDisabledReason ?? _t("voip|voice_call")}
onClick={(ev) => voiceCallClick(ev, callOptions[0])}
>
<VoiceCallIcon />
</IconButton>
</Tooltip>
);
const closeLobbyButton = (
<Tooltip label={_t("voip|close_lobby")}>
<IconButton onClick={toggleCall}>
<CloseCallIcon />
</IconButton>
</Tooltip>
);
let videoCallButton: JSX.Element | undefined = startVideoCallButton;
if (isConnectedToCall) {
videoCallButton = toggleCallButton;
} else if (isViewingCall) {
videoCallButton = closeLobbyButton;
}
if (!showVideoCallButton) {
videoCallButton = undefined;
}
if (!showVoiceCallButton) {
voiceCallButton = undefined;
}
const roomContext = useScopedRoomContext("mainSplitContentType");
const isVideoRoom = calcIsVideoRoom(room);
const showChatButton =
isVideoRoom ||
roomContext.mainSplitContentType === MainSplitContentType.MaximisedWidget ||
roomContext.mainSplitContentType === MainSplitContentType.Call;
const onAvatarClick = (): void => {
defaultDispatcher.dispatch({
action: "open_room_settings",
initial_tab_id: RoomSettingsTab.General,
});
};
return (
<>
<Flex as="header" align="center" gap="var(--cpd-space-3x)" className="mx_RoomHeader light-panel">
<WithPresenceIndicator room={room} size="8px">
{/* We hide this from the tabIndex list as it is a pointer shortcut and superfluous for a11y */}
<RoomAvatar
room={room}
size="40px"
oobData={oobData}
onClick={onAvatarClick}
tabIndex={-1}
aria-label={_t("room|header_avatar_open_settings_label")}
/>
</WithPresenceIndicator>
<button
aria-label={_t("right_panel|room_summary_card|title")}
tabIndex={0}
onClick={() => RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary)}
className="mx_RoomHeader_infoWrapper"
>
<Box flex="1" className="mx_RoomHeader_info">
<BodyText
as="div"
size="lg"
weight="semibold"
dir="auto"
role="heading"
aria-level={1}
className="mx_RoomHeader_heading"
>
<span className="mx_RoomHeader_truncated mx_lineClamp">{roomName}</span>
{!isDirectMessage && joinRule === JoinRule.Public && (
<Tooltip label={_t("common|public_room")} placement="right">
<PublicIcon
width="16px"
height="16px"
className="mx_RoomHeader_icon text-secondary"
aria-label={_t("common|public_room")}
/>
</Tooltip>
)}
{isDirectMessage && e2eStatus === E2EStatus.Verified && (
<Tooltip label={_t("common|verified")} placement="right">
<VerifiedIcon
width="16px"
height="16px"
className="mx_RoomHeader_icon mx_Verified"
aria-label={_t("common|verified")}
/>
</Tooltip>
)}
{isDirectMessage && e2eStatus === E2EStatus.Warning && (
<Tooltip label={_t("room|header_untrusted_label")} placement="right">
<ErrorIcon
width="16px"
height="16px"
className="mx_RoomHeader_icon mx_Untrusted"
aria-label={_t("room|header_untrusted_label")}
/>
</Tooltip>
)}
</BodyText>
</Box>
</button>
{additionalButtons?.map((props) => {
const label = props.label();
return (
<Tooltip label={label} key={props.id}>
<IconButton
aria-label={label}
onClick={(event) => {
event.stopPropagation();
props.onClick();
}}
>
{typeof props.icon === "function" ? props.icon() : props.icon}
</IconButton>
</Tooltip>
);
})}
{isViewingCall && <CallGuestLinkButton room={room} />}
{hasActiveCallSession && !isConnectedToCall && !isViewingCall ? (
joinCallButton
) : (
<>
{!isVideoRoom && videoCallButton}
{!useElementCallExclusively && !isVideoRoom && voiceCallButton}
</>
)}
{showChatButton && <VideoRoomChatButton room={room} />}
<Tooltip label={_t("common|threads")}>
<IconButton
indicator={notificationLevelToIndicator(threadNotifications)}
onClick={(evt) => {
evt.stopPropagation();
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.ThreadPanel);
PosthogTrackers.trackInteraction("WebRoomHeaderButtonsThreadsButton", evt);
}}
aria-label={_t("common|threads")}
>
<ThreadsIcon />
</IconButton>
</Tooltip>
{notificationsEnabled && (
<Tooltip label={_t("notifications|enable_prompt_toast_title")}>
<IconButton
indicator={notificationLevelToIndicator(globalNotificationState.level)}
onClick={(evt) => {
evt.stopPropagation();
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.NotificationPanel);
}}
aria-label={_t("notifications|enable_prompt_toast_title")}
>
<NotificationsIcon />
</IconButton>
</Tooltip>
)}
<Tooltip label={_t("right_panel|room_summary_card|title")}>
<IconButton
onClick={(evt) => {
evt.stopPropagation();
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary);
}}
aria-label={_t("right_panel|room_summary_card|title")}
>
<RoomInfoIcon />
</IconButton>
</Tooltip>
{!isDirectMessage && (
<BodyText as="div" size="sm" weight="medium">
<FacePile
className="mx_RoomHeader_members"
members={members.slice(0, 3)}
size="20px"
overflow={false}
viewUserOnClick={false}
tooltipLabel={_t("room|header_face_pile_tooltip")}
onClick={(e: ButtonEvent) => {
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.MemberList);
e.stopPropagation();
}}
aria-label={_t("common|n_members", { count: memberCount })}
>
{formatCount(memberCount)}
</FacePile>
</BodyText>
)}
</Flex>
{askToJoinEnabled && <RoomKnocksBar room={room} />}
</>
);
}
@@ -0,0 +1,412 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { useCallback, useMemo, useState } from "react";
import { Body as BodyText, Button, IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web";
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
import VoiceCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/voice-call";
import CloseCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
import ThreadsIcon from "@vector-im/compound-design-tokens/assets/web/icons/threads-solid";
import RoomInfoIcon from "@vector-im/compound-design-tokens/assets/web/icons/info-solid";
import NotificationsIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-solid";
import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error";
import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public";
import { JoinRule, type Room } from "matrix-js-sdk/src/matrix";
import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
import { useRoomName } from "../../../../hooks/useRoomName.ts";
import { RightPanelPhases } from "../../../../stores/right-panel/RightPanelStorePhases.ts";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext.tsx";
import { useRoomMemberCount, useRoomMembers } from "../../../../hooks/useRoomMembers.ts";
import { _t } from "../../../../languageHandler.tsx";
import { Flex } from "../../../utils/Flex.tsx";
import { Box } from "../../../utils/Box.tsx";
import { getPlatformCallTypeProps, useRoomCall } from "../../../../hooks/room/useRoomCall.tsx";
import { useRoomThreadNotifications } from "../../../../hooks/room/useRoomThreadNotifications.ts";
import { useGlobalNotificationState } from "../../../../hooks/useGlobalNotificationState.ts";
import SdkConfig from "../../../../SdkConfig.ts";
import { useFeatureEnabled } from "../../../../hooks/useSettings.ts";
import { useEncryptionStatus } from "../../../../hooks/useEncryptionStatus.ts";
import { E2EStatus } from "../../../../utils/ShieldUtils.ts";
import FacePile from "../../elements/FacePile.tsx";
import { useRoomState } from "../../../../hooks/useRoomState.ts";
import RoomAvatar from "../../avatars/RoomAvatar.tsx";
import { formatCount } from "../../../../utils/FormattingUtils.ts";
import RightPanelStore from "../../../../stores/right-panel/RightPanelStore.ts";
import PosthogTrackers from "../../../../PosthogTrackers.ts";
import { VideoRoomChatButton } from "./VideoRoomChatButton.tsx";
import { RoomKnocksBar } from "../RoomKnocksBar.tsx";
import { isVideoRoom as calcIsVideoRoom } from "../../../../utils/video-rooms.ts";
import { notificationLevelToIndicator } from "../../../../utils/notifications.ts";
import { CallGuestLinkButton } from "./CallGuestLinkButton.tsx";
import { ButtonEvent } from "../../elements/AccessibleButton.tsx";
import WithPresenceIndicator, { useDmMember } from "../../avatars/WithPresenceIndicator.tsx";
import { IOOBData } from "../../../../stores/ThreepidInviteStore.ts";
import { MainSplitContentType } from "../../../structures/RoomView.tsx";
import defaultDispatcher from "../../../../dispatcher/dispatcher.ts";
import { RoomSettingsTab } from "../../dialogs/RoomSettingsDialog.tsx";
import { useScopedRoomContext } from "../../../../contexts/ScopedRoomContext.tsx";
import { ToggleableIcon } from "./toggle/ToggleableIcon.tsx";
import { CurrentRightPanelPhaseContextProvider } from "../../../../contexts/CurrentRightPanelPhaseContext.tsx";
export default function RoomHeader({
room,
additionalButtons,
oobData,
}: {
room: Room;
additionalButtons?: ViewRoomOpts["buttons"];
oobData?: IOOBData;
}): JSX.Element {
const client = useMatrixClientContext();
const roomName = useRoomName(room);
const joinRule = useRoomState(room, (state) => state.getJoinRule());
const members = useRoomMembers(room, 2500);
const memberCount = useRoomMemberCount(room, { throttleWait: 2500 });
const {
voiceCallDisabledReason,
voiceCallClick,
videoCallDisabledReason,
videoCallClick,
toggleCallMaximized: toggleCall,
isViewingCall,
isConnectedToCall,
hasActiveCallSession,
callOptions,
showVoiceCallButton,
showVideoCallButton,
} = useRoomCall(room);
const groupCallsEnabled = useFeatureEnabled("feature_group_calls");
/**
* A special mode where only Element Call is used. In this case we want to
* hide the voice call button
*/
const useElementCallExclusively = useMemo(() => {
return SdkConfig.get("element_call").use_exclusively && groupCallsEnabled;
}, [groupCallsEnabled]);
const threadNotifications = useRoomThreadNotifications(room);
const globalNotificationState = useGlobalNotificationState();
const dmMember = useDmMember(room);
const isDirectMessage = !!dmMember;
const e2eStatus = useEncryptionStatus(client, room);
const notificationsEnabled = useFeatureEnabled("feature_notifications");
const askToJoinEnabled = useFeatureEnabled("feature_ask_to_join");
const videoClick = useCallback(
(ev: React.MouseEvent) => videoCallClick(ev, callOptions[0]),
[callOptions, videoCallClick],
);
const toggleCallButton = (
<Tooltip label={isViewingCall ? _t("voip|minimise_call") : _t("voip|maximise_call")}>
<IconButton onClick={toggleCall}>
<VideoCallIcon />
</IconButton>
</Tooltip>
);
const joinCallButton = (
<Tooltip label={videoCallDisabledReason ?? _t("voip|video_call")}>
<Button
size="sm"
onClick={videoClick}
Icon={VideoCallIcon}
className="mx_RoomHeader_join_button"
disabled={!!videoCallDisabledReason}
color="primary"
aria-label={videoCallDisabledReason ?? _t("action|join")}
>
{_t("action|join")}
</Button>
</Tooltip>
);
const callIconWithTooltip = (
<Tooltip label={videoCallDisabledReason ?? _t("voip|video_call")}>
<VideoCallIcon />
</Tooltip>
);
const [menuOpen, setMenuOpen] = useState(false);
const onOpenChange = useCallback(
(newOpen: boolean) => {
if (!videoCallDisabledReason) setMenuOpen(newOpen);
},
[videoCallDisabledReason],
);
const startVideoCallButton = (
<>
{/* Can be either a menu or just a button depending on the number of call options.*/}
{callOptions.length > 1 ? (
<Menu
open={menuOpen}
onOpenChange={onOpenChange}
title={_t("voip|video_call_using")}
trigger={
<IconButton
disabled={!!videoCallDisabledReason}
aria-label={videoCallDisabledReason ?? _t("voip|video_call")}
>
{callIconWithTooltip}
</IconButton>
}
side="left"
align="start"
>
{callOptions.map((option) => {
const { label, children } = getPlatformCallTypeProps(option);
return (
<MenuItem
key={option}
label={label}
aria-label={label}
children={children}
className="mx_RoomHeader_videoCallOption"
onClick={(ev) => videoCallClick(ev, option)}
Icon={VideoCallIcon}
onSelect={() => {} /* Dummy handler since we want the click event.*/}
/>
);
})}
</Menu>
) : (
<IconButton
disabled={!!videoCallDisabledReason}
aria-label={videoCallDisabledReason ?? _t("voip|video_call")}
onClick={videoClick}
>
{callIconWithTooltip}
</IconButton>
)}
</>
);
let voiceCallButton: JSX.Element | undefined = (
<Tooltip label={voiceCallDisabledReason ?? _t("voip|voice_call")}>
<IconButton
// We need both: isViewingCall and isConnectedToCall
// - in the Lobby we are viewing a call but are not connected to it.
// - in pip view we are connected to the call but not viewing it.
disabled={!!voiceCallDisabledReason || isViewingCall || isConnectedToCall}
aria-label={voiceCallDisabledReason ?? _t("voip|voice_call")}
onClick={(ev) => voiceCallClick(ev, callOptions[0])}
>
<VoiceCallIcon />
</IconButton>
</Tooltip>
);
const closeLobbyButton = (
<Tooltip label={_t("voip|close_lobby")}>
<IconButton onClick={toggleCall}>
<CloseCallIcon />
</IconButton>
</Tooltip>
);
let videoCallButton: JSX.Element | undefined = startVideoCallButton;
if (isConnectedToCall) {
videoCallButton = toggleCallButton;
} else if (isViewingCall) {
videoCallButton = closeLobbyButton;
}
if (!showVideoCallButton) {
videoCallButton = undefined;
}
if (!showVoiceCallButton) {
voiceCallButton = undefined;
}
const roomContext = useScopedRoomContext("mainSplitContentType");
const isVideoRoom = calcIsVideoRoom(room);
const showChatButton =
isVideoRoom ||
roomContext.mainSplitContentType === MainSplitContentType.MaximisedWidget ||
roomContext.mainSplitContentType === MainSplitContentType.Call;
const onAvatarClick = (): void => {
defaultDispatcher.dispatch({
action: "open_room_settings",
initial_tab_id: RoomSettingsTab.General,
});
};
return (
<>
<CurrentRightPanelPhaseContextProvider roomId={room.roomId}>
<Flex as="header" align="center" gap="var(--cpd-space-3x)" className="mx_RoomHeader light-panel">
<WithPresenceIndicator room={room} size="8px">
{/* We hide this from the tabIndex list as it is a pointer shortcut and superfluous for a11y */}
<RoomAvatar
room={room}
size="40px"
oobData={oobData}
onClick={onAvatarClick}
tabIndex={-1}
aria-label={_t("room|header_avatar_open_settings_label")}
/>
</WithPresenceIndicator>
<button
aria-label={_t("right_panel|room_summary_card|title")}
tabIndex={0}
onClick={() => RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary)}
className="mx_RoomHeader_infoWrapper"
>
<Box flex="1" className="mx_RoomHeader_info">
<BodyText
as="div"
size="lg"
weight="semibold"
dir="auto"
role="heading"
aria-level={1}
className="mx_RoomHeader_heading"
>
<span className="mx_RoomHeader_truncated mx_lineClamp">{roomName}</span>
{!isDirectMessage && joinRule === JoinRule.Public && (
<Tooltip label={_t("common|public_room")} placement="right">
<PublicIcon
width="16px"
height="16px"
className="mx_RoomHeader_icon text-secondary"
aria-label={_t("common|public_room")}
/>
</Tooltip>
)}
{isDirectMessage && e2eStatus === E2EStatus.Verified && (
<Tooltip label={_t("common|verified")} placement="right">
<VerifiedIcon
width="16px"
height="16px"
className="mx_RoomHeader_icon mx_Verified"
aria-label={_t("common|verified")}
/>
</Tooltip>
)}
{isDirectMessage && e2eStatus === E2EStatus.Warning && (
<Tooltip label={_t("room|header_untrusted_label")} placement="right">
<ErrorIcon
width="16px"
height="16px"
className="mx_RoomHeader_icon mx_Untrusted"
aria-label={_t("room|header_untrusted_label")}
/>
</Tooltip>
)}
</BodyText>
</Box>
</button>
{additionalButtons?.map((props) => {
const label = props.label();
return (
<Tooltip label={label} key={props.id}>
<IconButton
aria-label={label}
onClick={(event) => {
event.stopPropagation();
props.onClick();
}}
>
{typeof props.icon === "function" ? props.icon() : props.icon}
</IconButton>
</Tooltip>
);
})}
{isViewingCall && <CallGuestLinkButton room={room} />}
{hasActiveCallSession && !isConnectedToCall && !isViewingCall ? (
joinCallButton
) : (
<>
{!isVideoRoom && videoCallButton}
{!useElementCallExclusively && !isVideoRoom && voiceCallButton}
</>
)}
{showChatButton && <VideoRoomChatButton room={room} />}
<Tooltip label={_t("common|threads")}>
<IconButton
indicator={notificationLevelToIndicator(threadNotifications)}
onClick={(evt) => {
evt.stopPropagation();
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.ThreadPanel);
PosthogTrackers.trackInteraction("WebRoomHeaderButtonsThreadsButton", evt);
}}
aria-label={_t("common|threads")}
>
<ToggleableIcon Icon={ThreadsIcon} phase={RightPanelPhases.ThreadPanel} />
</IconButton>
</Tooltip>
{notificationsEnabled && (
<Tooltip label={_t("notifications|enable_prompt_toast_title")}>
<IconButton
indicator={notificationLevelToIndicator(globalNotificationState.level)}
onClick={(evt) => {
evt.stopPropagation();
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.NotificationPanel);
}}
aria-label={_t("notifications|enable_prompt_toast_title")}
>
<ToggleableIcon Icon={NotificationsIcon} phase={RightPanelPhases.NotificationPanel} />
</IconButton>
</Tooltip>
)}
<Tooltip label={_t("right_panel|room_summary_card|title")}>
<IconButton
onClick={(evt) => {
evt.stopPropagation();
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary);
}}
aria-label={_t("right_panel|room_summary_card|title")}
>
<ToggleableIcon Icon={RoomInfoIcon} phase={RightPanelPhases.RoomSummary} />
</IconButton>
</Tooltip>
{!isDirectMessage && (
<BodyText as="div" size="sm" weight="medium">
<FacePile
className="mx_RoomHeader_members"
members={members.slice(0, 3)}
size="20px"
overflow={false}
viewUserOnClick={false}
tooltipLabel={_t("room|header_face_pile_tooltip")}
onClick={(e: ButtonEvent) => {
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.MemberList);
e.stopPropagation();
}}
aria-label={_t("common|n_members", { count: memberCount })}
>
{formatCount(memberCount)}
</FacePile>
</BodyText>
)}
</Flex>
{askToJoinEnabled && <RoomKnocksBar room={room} />}
</CurrentRightPanelPhaseContextProvider>
</>
);
}
@@ -18,6 +18,7 @@ import { NotificationLevel } from "../../../../stores/notifications/Notification
import { RightPanelPhases } from "../../../../stores/right-panel/RightPanelStorePhases";
import { SDKContext } from "../../../../contexts/SDKContext";
import { ButtonEvent } from "../../elements/AccessibleButton";
import { ToggleableIcon } from "./toggle/ToggleableIcon";
/**
* Display a button to toggle timeline for video rooms
@@ -54,7 +55,7 @@ export const VideoRoomChatButton: React.FC<{ room: Room }> = ({ room }) => {
onClick={onClick}
indicator={displayUnreadIndicator ? "default" : undefined}
>
<ChatIcon />
<ToggleableIcon Icon={ChatIcon} phase={RightPanelPhases.Timeline} />
</IconButton>
</Tooltip>
);
@@ -0,0 +1,30 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import classNames from "classnames";
import { RightPanelPhases } from "../../../../../stores/right-panel/RightPanelStorePhases";
import { useToggled } from "./useToggled";
type Props = {
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
phase: RightPanelPhases;
};
/**
* Use this component for room header icons that toggle different right panel phases.
* Will add a class to the icon when the specified phase is on.
*/
export function ToggleableIcon({ Icon, phase }: Props): React.ReactElement {
const toggled = useToggled(phase);
const highlightClass = classNames({
mx_RoomHeader_toggled: toggled,
});
return <Icon className={highlightClass} />;
}
@@ -0,0 +1,23 @@
/*
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 { useContext } from "react";
import { RightPanelPhases } from "../../../../../stores/right-panel/RightPanelStorePhases";
import { CurrentRightPanelPhaseContext } from "../../../../../contexts/CurrentRightPanelPhaseContext";
/**
* Hook to easily track whether a given right panel phase is toggled on/off.
*/
export function useToggled(phase: RightPanelPhases): boolean {
const context = useContext(CurrentRightPanelPhaseContext);
if (!context) {
return false;
}
const { currentPhase, isPanelOpen } = context;
return !!(isPanelOpen && currentPhase === phase);
}
@@ -5,16 +5,18 @@ 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, { useCallback, useEffect, useRef, useState } from "react";
import { EventType, KnownMembership, MatrixEvent, Room, RoomStateEvent, RoomMember } from "matrix-js-sdk/src/matrix";
import { CryptoApi, CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger";
import React from "react";
import { Room, RoomMember } from "matrix-js-sdk/src/matrix";
import { Button, Separator } from "@vector-im/compound-web";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import MemberAvatar from "../avatars/MemberAvatar";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import {
useUserIdentityWarningViewModel,
ViolationPrompt,
} from "../../viewmodels/rooms/UserIdentityWarningViewModel.tsx";
import { ButtonEvent } from "../elements/AccessibleButton.tsx";
interface UserIdentityWarningProps {
/**
@@ -28,24 +30,6 @@ interface UserIdentityWarningProps {
key: string;
}
/**
* Does the given user's identity need to be approved?
*/
async function userNeedsApproval(crypto: CryptoApi, userId: string): Promise<boolean> {
const verificationStatus = await crypto.getUserVerificationStatus(userId);
return verificationStatus.needsUserApproval;
}
/**
* Whether the component is uninitialised, is in the process of initialising, or
* has completed initialising.
*/
enum InitialisationStatus {
Uninitialised,
Initialising,
Completed,
}
/**
* Displays a banner warning when there is an issue with a user's identity.
*
@@ -53,283 +37,101 @@ enum InitialisationStatus {
* button to acknowledge the change.
*/
export const UserIdentityWarning: React.FC<UserIdentityWarningProps> = ({ room }) => {
const cli = useMatrixClientContext();
const crypto = cli.getCrypto();
const { currentPrompt, dispatchAction } = useUserIdentityWarningViewModel(room, room.roomId);
// The current room member that we are prompting the user to approve.
// `undefined` means we are not currently showing a prompt.
const [currentPrompt, setCurrentPrompt] = useState<RoomMember | undefined>(undefined);
if (!currentPrompt) return null;
// Whether or not we've already initialised the component by loading the
// room membership.
const initialisedRef = useRef<InitialisationStatus>(InitialisationStatus.Uninitialised);
// Which room members need their identity approved.
const membersNeedingApprovalRef = useRef<Map<string, RoomMember>>(new Map());
// For each user, we assign a sequence number to each verification status
// that we get, or fetch.
//
// Since fetching a verification status is asynchronous, we could get an
// update in the middle of fetching the verification status, which could
// mean that the status that we fetched is out of date. So if the current
// sequence number is not the same as the sequence number when we started
// the fetch, then we drop our fetched result, under the assumption that the
// update that we received is the most up-to-date version. If it is in fact
// not the most up-to-date version, then we should be receiving a new update
// soon with the newer value, so it will fix itself in the end.
//
// We also assign a sequence number when the user leaves the room, in order
// to prevent prompting about a user who leaves while we are fetching their
// verification status.
const verificationStatusSequencesRef = useRef<Map<string, number>>(new Map());
const incrementVerificationStatusSequence = (userId: string): number => {
const verificationStatusSequences = verificationStatusSequencesRef.current;
const value = verificationStatusSequences.get(userId);
const newValue = value === undefined ? 1 : value + 1;
verificationStatusSequences.set(userId, newValue);
return newValue;
const [title, action] = getTitleAndAction(currentPrompt);
const onButtonClick = (ev: ButtonEvent): void => {
ev.preventDefault();
if (currentPrompt.type === "VerificationViolation") {
dispatchAction({ type: "WithdrawVerification", userId: currentPrompt.member.userId });
} else {
dispatchAction({ type: "PinUserIdentity", userId: currentPrompt.member.userId });
}
};
// Update the current prompt. Select a new user if needed, or hide the
// warning if we don't have anyone to warn about.
const updateCurrentPrompt = useCallback((): undefined => {
const membersNeedingApproval = membersNeedingApprovalRef.current;
// We have to do this in a callback to `setCurrentPrompt`
// because this function could have been called after an
// `await`, and the `currentPrompt` that this function would
// have may be outdated.
setCurrentPrompt((currentPrompt) => {
// If we're already displaying a warning, and that user still needs
// approval, continue showing that user.
if (currentPrompt && membersNeedingApproval.has(currentPrompt.userId)) return currentPrompt;
if (membersNeedingApproval.size === 0) {
if (currentPrompt) {
// If we were previously showing a warning, log that we've stopped doing so.
logger.debug("UserIdentityWarning: no users left that need approval");
}
return undefined;
}
// We pick the user with the smallest user ID.
const keys = Array.from(membersNeedingApproval.keys()).sort((a, b) => a.localeCompare(b));
const selection = membersNeedingApproval.get(keys[0]!);
logger.debug(`UserIdentityWarning: now warning about user ${selection?.userId}`);
return selection;
});
}, []);
// Add a user to the membersNeedingApproval map, and update the current
// prompt if necessary. The user will only be added if they are actually a
// member of the room. If they are not a member, this function will do
// nothing.
const addMemberNeedingApproval = useCallback(
(userId: string, member?: RoomMember): void => {
if (userId === cli.getUserId()) {
// We always skip our own user, because we can't pin our own identity.
return;
}
member = member ?? room.getMember(userId) ?? undefined;
if (!member) return;
membersNeedingApprovalRef.current.set(userId, member);
// We only select the prompt if we are done initialising,
// because we will select the prompt after we're done
// initialising, and we want to start by displaying a warning
// for the user with the smallest ID.
if (initialisedRef.current === InitialisationStatus.Completed) {
logger.debug(
`UserIdentityWarning: user ${userId} now needs approval; approval-pending list now [${Array.from(membersNeedingApprovalRef.current.keys())}]`,
);
updateCurrentPrompt();
}
},
[cli, room, updateCurrentPrompt],
return warningBanner(
currentPrompt.type === "VerificationViolation",
memberAvatar(currentPrompt.member),
title,
action,
onButtonClick,
);
};
// For each user in the list check if their identity needs approval, and if
// so, add them to the membersNeedingApproval map and update the prompt if
// needed.
const addMembersWhoNeedApproval = useCallback(
async (members: RoomMember[]): Promise<void> => {
const verificationStatusSequences = verificationStatusSequencesRef.current;
const promises: Promise<void>[] = [];
for (const member of members) {
const userId = member.userId;
const sequenceNum = incrementVerificationStatusSequence(userId);
promises.push(
userNeedsApproval(crypto!, userId).then((needsApproval) => {
if (needsApproval) {
// Only actually update the list if we have the most
// recent value.
if (verificationStatusSequences.get(userId) === sequenceNum) {
addMemberNeedingApproval(userId, member);
}
}
}),
);
}
await Promise.all(promises);
},
[crypto, addMemberNeedingApproval],
);
// Remove a user from the membersNeedingApproval map, and update the current
// prompt if necessary.
const removeMemberNeedingApproval = useCallback(
(userId: string): void => {
membersNeedingApprovalRef.current.delete(userId);
logger.debug(
`UserIdentityWarning: user ${userId} no longer needs approval; approval-pending list now [${Array.from(membersNeedingApprovalRef.current.keys())}]`,
function getTitleAndAction(prompt: ViolationPrompt): [title: React.ReactNode, action: string] {
let title: React.ReactNode;
let action: string;
if (prompt.type === "VerificationViolation") {
if (prompt.member.rawDisplayName === prompt.member.userId) {
title = _t(
"encryption|verified_identity_changed_no_displayname",
{ userId: prompt.member.userId },
{
a: substituteATag,
b: substituteBTag,
},
);
} else {
title = _t(
"encryption|verified_identity_changed",
{ displayName: prompt.member.rawDisplayName, userId: prompt.member.userId },
{
a: substituteATag,
b: substituteBTag,
},
);
updateCurrentPrompt();
},
[updateCurrentPrompt],
);
// Initialise the component. Get the room members, check which ones need
// their identity approved, and pick one to display.
const loadMembers = useCallback(async (): Promise<void> => {
if (!crypto || initialisedRef.current !== InitialisationStatus.Uninitialised) {
return;
}
// If encryption is not enabled in the room, we don't need to do
// anything. If encryption gets enabled later, we will retry, via
// onRoomStateEvent.
if (!(await crypto.isEncryptionEnabledInRoom(room.roomId))) {
return;
action = _t("encryption|withdraw_verification_action");
} else {
if (prompt.member.rawDisplayName === prompt.member.userId) {
title = _t(
"encryption|pinned_identity_changed_no_displayname",
{ userId: prompt.member.userId },
{
a: substituteATag,
b: substituteBTag,
},
);
} else {
title = _t(
"encryption|pinned_identity_changed",
{ displayName: prompt.member.rawDisplayName, userId: prompt.member.userId },
{
a: substituteATag,
b: substituteBTag,
},
);
}
initialisedRef.current = InitialisationStatus.Initialising;
const members = await room.getEncryptionTargetMembers();
await addMembersWhoNeedApproval(members);
logger.info(
`Initialised UserIdentityWarning component for room ${room.roomId} with approval-pending list [${Array.from(membersNeedingApprovalRef.current.keys())}]`,
);
updateCurrentPrompt();
initialisedRef.current = InitialisationStatus.Completed;
}, [crypto, room, addMembersWhoNeedApproval, updateCurrentPrompt]);
useEffect(() => {
loadMembers().catch((e) => {
logger.error("Error initialising UserIdentityWarning:", e);
});
}, [loadMembers]);
// When a user's verification status changes, we check if they need to be
// added/removed from the set of members needing approval.
const onUserVerificationStatusChanged = useCallback(
(userId: string, verificationStatus: UserVerificationStatus): void => {
// If we haven't started initialising, that means that we're in a
// room where we don't need to display any warnings.
if (initialisedRef.current === InitialisationStatus.Uninitialised) {
return;
}
incrementVerificationStatusSequence(userId);
if (verificationStatus.needsUserApproval) {
addMemberNeedingApproval(userId);
} else {
removeMemberNeedingApproval(userId);
}
},
[addMemberNeedingApproval, removeMemberNeedingApproval],
);
useTypedEventEmitter(cli, CryptoEvent.UserTrustStatusChanged, onUserVerificationStatusChanged);
// We watch for encryption events (since we only display warnings in
// encrypted rooms), and for membership changes (since we only display
// warnings for users in the room).
const onRoomStateEvent = useCallback(
async (event: MatrixEvent): Promise<void> => {
if (!crypto || event.getRoomId() !== room.roomId) {
return;
}
const eventType = event.getType();
if (eventType === EventType.RoomEncryption && event.getStateKey() === "") {
// Room is now encrypted, so we can initialise the component.
return loadMembers().catch((e) => {
logger.error("Error initialising UserIdentityWarning:", e);
});
} else if (eventType !== EventType.RoomMember) {
return;
}
// We're processing an m.room.member event
if (initialisedRef.current === InitialisationStatus.Uninitialised) {
return;
}
const userId = event.getStateKey();
if (!userId) return;
if (
event.getContent().membership === KnownMembership.Join ||
(event.getContent().membership === KnownMembership.Invite && room.shouldEncryptForInvitedMembers())
) {
// Someone's membership changed and we will now encrypt to them. If
// their identity needs approval, show a warning.
const member = room.getMember(userId);
if (member) {
await addMembersWhoNeedApproval([member]).catch((e) => {
logger.error("Error adding member in UserIdentityWarning:", e);
});
}
} else {
// Someone's membership changed and we no longer encrypt to them.
// If we're showing a warning about them, we don't need to any more.
removeMemberNeedingApproval(userId);
incrementVerificationStatusSequence(userId);
}
},
[crypto, room, addMembersWhoNeedApproval, removeMemberNeedingApproval, loadMembers],
);
useTypedEventEmitter(cli, RoomStateEvent.Events, onRoomStateEvent);
if (!crypto || !currentPrompt) return null;
const confirmIdentity = async (): Promise<void> => {
await crypto.pinCurrentUserIdentity(currentPrompt.userId);
};
action = _t("action|ok");
}
return [title, action];
}
function warningBanner(
isCritical: boolean,
avatar: React.ReactNode,
title: React.ReactNode,
action: string,
onButtonClick: (ev: ButtonEvent) => void,
): React.ReactNode {
return (
<div className="mx_UserIdentityWarning">
<div className={classNames("mx_UserIdentityWarning", { critical: isCritical })}>
<Separator />
<div className="mx_UserIdentityWarning_row">
<MemberAvatar member={currentPrompt} title={currentPrompt.userId} size="30px" />
<span className="mx_UserIdentityWarning_main">
{currentPrompt.rawDisplayName === currentPrompt.userId
? _t(
"encryption|pinned_identity_changed_no_displayname",
{ userId: currentPrompt.userId },
{
a: substituteATag,
b: substituteBTag,
},
)
: _t(
"encryption|pinned_identity_changed",
{ displayName: currentPrompt.rawDisplayName, userId: currentPrompt.userId },
{
a: substituteATag,
b: substituteBTag,
},
)}
</span>
<Button kind="primary" size="sm" onClick={confirmIdentity}>
{_t("action|ok")}
{avatar}
<span className={classNames("mx_UserIdentityWarning_main", { critical: isCritical })}>{title}</span>
<Button kind="secondary" size="sm" onClick={onButtonClick}>
{action}
</Button>
</div>
</div>
);
};
}
function memberAvatar(member: RoomMember): React.ReactNode {
return <MemberAvatar member={member} title={member.userId} size="30px" />;
}
function substituteATag(sub: string): React.ReactNode {
return (
@@ -31,8 +31,7 @@ export function shouldShowQr(
): boolean {
const msc4108Supported = !!versions?.unstable_features?.["org.matrix.msc4108"];
const deviceAuthorizationGrantSupported =
oidcClientConfig?.metadata?.grant_types_supported.includes(DEVICE_CODE_SCOPE);
const deviceAuthorizationGrantSupported = oidcClientConfig?.grant_types_supported.includes(DEVICE_CODE_SCOPE);
return (
!!deviceAuthorizationGrantSupported &&
@@ -58,7 +58,7 @@ export const DiscoverySettings: React.FC = () => {
agreedUrls: null, // From the startTermsFlow callback
resolve: null, // Promise resolve function for startTermsFlow callback
});
const [hasTerms, setHasTerms] = useState<boolean>(false);
const [mustAgreeToTerms, setMustAgreeToTerms] = useState<boolean>(false);
const getThreepidState = useCallback(async () => {
setIsLoadingThreepids(true);
@@ -103,7 +103,7 @@ export const DiscoverySettings: React.FC = () => {
(policiesAndServices, agreedUrls, extraClassNames) => {
return new Promise((resolve) => {
setIdServerName(abbreviateUrl(idServerUrl));
setHasTerms(true);
setMustAgreeToTerms(true);
setRequiredPolicyInfo({
policiesAndServices,
agreedUrls,
@@ -113,7 +113,7 @@ export const DiscoverySettings: React.FC = () => {
},
);
// User accepted all terms
setHasTerms(false);
setMustAgreeToTerms(false);
} catch (e) {
logger.warn(
`Unable to reach identity server at ${idServerUrl} to check ` + `for terms in Settings`,
@@ -126,7 +126,7 @@ export const DiscoverySettings: React.FC = () => {
if (!SettingsStore.getValue(UIFeature.ThirdPartyID)) return null;
if (hasTerms && requiredPolicyInfo.policiesAndServices) {
if (mustAgreeToTerms && requiredPolicyInfo.policiesAndServices) {
const intro = (
<Alert type="info" title={_t("settings|general|discovery_needs_terms_title")}>
{_t("settings|general|discovery_needs_terms", { serverName: idServerName })}
@@ -160,7 +160,7 @@ export const DiscoverySettings: React.FC = () => {
medium={ThreepidMedium.Email}
threepids={emails}
onChange={getThreepidState}
disabled={!hasTerms}
disabled={mustAgreeToTerms}
isLoading={isLoadingThreepids}
/>
</SettingsSubsection>
@@ -174,7 +174,7 @@ export const DiscoverySettings: React.FC = () => {
medium={ThreepidMedium.Phone}
threepids={phoneNumbers}
onChange={getThreepidState}
disabled={!hasTerms}
disabled={mustAgreeToTerms}
isLoading={isLoadingThreepids}
/>
</SettingsSubsection>
@@ -0,0 +1,139 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { JSX, lazy, MouseEventHandler } from "react";
import { Button, HelpMessage, InlineField, InlineSpinner, Label, Root, ToggleControl } from "@vector-im/compound-web";
import DownloadIcon from "@vector-im/compound-design-tokens/assets/web/icons/download";
import ShareIcon from "@vector-im/compound-design-tokens/assets/web/icons/share";
import { _t } from "../../../../languageHandler";
import { SettingsSection } from "../shared/SettingsSection";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
import Modal from "../../../../Modal";
import { SettingLevel } from "../../../../settings/SettingLevel";
import { useSettingValueAt } from "../../../../hooks/useSettings";
import SettingsStore from "../../../../settings/SettingsStore";
interface AdvancedPanelProps {
/**
* Callback for when the user clicks the button to reset their identity.
*/
onResetIdentityClick: MouseEventHandler<HTMLButtonElement>;
}
/**
* The advanced panel of the encryption settings.
*/
export function AdvancedPanel({ onResetIdentityClick }: AdvancedPanelProps): JSX.Element {
return (
<SettingsSection heading={_t("settings|encryption|advanced|title")} legacy={false}>
<EncryptionDetails onResetIdentityClick={onResetIdentityClick} />
<OtherSettings />
</SettingsSection>
);
}
interface EncryptionDetails {
/**
* Callback for when the user clicks the button to reset their identity.
*/
onResetIdentityClick: MouseEventHandler<HTMLButtonElement>;
}
/**
* The encryption details section of the advanced panel.
*/
function EncryptionDetails({ onResetIdentityClick }: EncryptionDetails): JSX.Element {
const matrixClient = useMatrixClientContext();
// Null when the keys are not loaded yet
const keys = useAsyncMemo(() => matrixClient.getCrypto()!.getOwnDeviceKeys(), [matrixClient], null);
return (
<div className="mx_EncryptionDetails" data-testid="encryptionDetails">
<div className="mx_EncryptionDetails_session">
<h3 className="mx_EncryptionDetails_session_title">
{_t("settings|encryption|advanced|details_title")}
</h3>
<div>
<span>{_t("settings|encryption|advanced|session_id")}</span>
<span data-testid="deviceId">{matrixClient.deviceId}</span>
</div>
<div>
<span>{_t("settings|encryption|advanced|session_key")}</span>
<span data-testid="sessionKey">
{keys ? keys.ed25519 : <InlineSpinner aria-label={_t("common|loading")} />}
</span>
</div>
</div>
<div className="mx_EncryptionDetails_buttons">
<Button
size="sm"
kind="secondary"
Icon={ShareIcon}
onClick={() =>
Modal.createDialog(
lazy(
() => import("../../../../async-components/views/dialogs/security/ExportE2eKeysDialog"),
),
{ matrixClient },
)
}
>
{_t("settings|encryption|advanced|export_keys")}
</Button>
<Button
size="sm"
kind="secondary"
Icon={DownloadIcon}
onClick={() =>
Modal.createDialog(
lazy(
() => import("../../../../async-components/views/dialogs/security/ImportE2eKeysDialog"),
),
{ matrixClient },
)
}
>
{_t("settings|encryption|advanced|import_keys")}
</Button>
</div>
<Button size="sm" kind="tertiary" destructive={true} onClick={onResetIdentityClick}>
{_t("settings|encryption|advanced|reset_identity")}
</Button>
</div>
);
}
/**
* Display the never send encrypted message to unverified devices setting.
*/
function OtherSettings(): JSX.Element | null {
const blacklistUnverifiedDevices = useSettingValueAt(SettingLevel.DEVICE, "blacklistUnverifiedDevices");
const canSetValue = SettingsStore.canSetValue("blacklistUnverifiedDevices", null, SettingLevel.DEVICE);
if (!canSetValue) return null;
return (
<Root
data-testid="otherSettings"
className="mx_OtherSettings"
onChange={async (evt) => {
const checked = new FormData(evt.currentTarget).get("neverSendEncrypted") === "on";
await SettingsStore.setValue("blacklistUnverifiedDevices", null, SettingLevel.DEVICE, checked);
}}
>
<h3 className="mx_OtherSettings_title">{_t("settings|encryption|advanced|other_people_device_title")}</h3>
<InlineField
name="neverSendEncrypted"
control={<ToggleControl name="neverSendEncrypted" defaultChecked={blacklistUnverifiedDevices} />}
>
<Label>{_t("settings|encryption|advanced|other_people_device_label")}</Label>
<HelpMessage>{_t("settings|encryption|advanced|other_people_device_description")}</HelpMessage>
</InlineField>
</Root>
);
}
@@ -18,6 +18,7 @@ import {
TextControl,
} from "@vector-im/compound-web";
import CopyIcon from "@vector-im/compound-design-tokens/assets/web/icons/copy";
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key-solid";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../../languageHandler";
@@ -157,7 +158,12 @@ export function ChangeRecoveryKey({
pages={pages}
onPageClick={onCancelClick}
/>
<EncryptionCard title={labels.title} description={labels.description} className="mx_ChangeRecoveryKey">
<EncryptionCard
Icon={KeyIcon}
title={labels.title}
description={labels.description}
className="mx_ChangeRecoveryKey"
>
{content}
</EncryptionCard>
</>
@@ -5,9 +5,8 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { JSX, PropsWithChildren } from "react";
import React, { JSX, PropsWithChildren, ComponentType, SVGAttributes } from "react";
import { BigIcon, Heading } from "@vector-im/compound-web";
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key-solid";
import classNames from "classnames";
interface EncryptionCardProps {
@@ -22,7 +21,15 @@ interface EncryptionCardProps {
/**
* The description of the card.
*/
description: string;
description?: string;
/**
* Whether this icon shows a destructive action.
*/
destructive?: boolean;
/**
* The icon to display.
*/
Icon: ComponentType<SVGAttributes<SVGElement>>;
}
/**
@@ -32,18 +39,20 @@ export function EncryptionCard({
title,
description,
className,
destructive = false,
Icon,
children,
}: PropsWithChildren<EncryptionCardProps>): JSX.Element {
return (
<div className={classNames("mx_EncryptionCard", className)}>
<div className="mx_EncryptionCard_header">
<BigIcon>
<KeyIcon />
<BigIcon destructive={destructive}>
<Icon />
</BigIcon>
<Heading as="h2" size="sm" weight="semibold">
{title}
</Heading>
<span>{description}</span>
{description && <span>{description}</span>}
</div>
{children}
</div>
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { JSX, useCallback, useEffect, useState } from "react";
import React, { JSX } from "react";
import { Button, InlineSpinner } from "@vector-im/compound-web";
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key";
@@ -13,18 +13,15 @@ import { SettingsSection } from "../shared/SettingsSection";
import { _t } from "../../../../languageHandler";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { SettingsHeader } from "../SettingsHeader";
import { accessSecretStorage } from "../../../../SecurityManager";
import { SettingsSubheader } from "../SettingsSubheader";
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
/**
* The possible states of the recovery panel.
* - `loading`: We are checking the recovery key and the secrets.
* - `missing_recovery_key`: The user has no recovery key.
* - `secrets_not_cached`: The user has a recovery key but the secrets are not cached.
* This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
* - `good`: The user has a recovery key and the secrets are cached.
*/
type State = "loading" | "missing_recovery_key" | "secrets_not_cached" | "good";
type State = "loading" | "missing_recovery_key" | "good";
interface RecoveryPanelProps {
/**
@@ -40,29 +37,18 @@ interface RecoveryPanelProps {
* This component allows the user to set up or change their recovery key.
*/
export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps): JSX.Element {
const [state, setState] = useState<State>("loading");
const isMissingRecoveryKey = state === "missing_recovery_key";
const matrixClient = useMatrixClientContext();
const checkEncryption = useCallback(async () => {
const crypto = matrixClient.getCrypto()!;
// Check if the user has a recovery key
const hasRecoveryKey = Boolean(await matrixClient.secretStorage.getDefaultKeyId());
if (!hasRecoveryKey) return setState("missing_recovery_key");
// Check if the secrets are cached
const cachedSecrets = (await crypto.getCrossSigningStatus()).privateKeysCachedLocally;
const secretsOk = cachedSecrets.masterKey && cachedSecrets.selfSigningKey && cachedSecrets.userSigningKey;
if (!secretsOk) return setState("secrets_not_cached");
setState("good");
}, [matrixClient]);
useEffect(() => {
checkEncryption();
}, [checkEncryption]);
const state = useAsyncMemo<State>(
async () => {
// Check if the user has a recovery key
const hasRecoveryKey = Boolean(await matrixClient.secretStorage.getDefaultKeyId());
if (hasRecoveryKey) return "good";
else return "missing_recovery_key";
},
[matrixClient],
"loading",
);
const isMissingRecoveryKey = state === "missing_recovery_key";
let content: JSX.Element;
switch (state) {
@@ -76,18 +62,6 @@ export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps):
</Button>
);
break;
case "secrets_not_cached":
content = (
<Button
size="sm"
kind="primary"
Icon={KeyIcon}
onClick={async () => await accessSecretStorage(checkEncryption)}
>
{_t("settings|encryption|recovery|enter_recovery_key")}
</Button>
);
break;
case "good":
content = (
<Button size="sm" kind="secondary" Icon={KeyIcon} onClick={() => onChangeRecoveryKeyClick(false)}>
@@ -105,32 +79,10 @@ export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps):
label={_t("settings|encryption|recovery|title")}
/>
}
subHeading={<Subheader state={state} />}
subHeading={_t("settings|encryption|recovery|description")}
data-testid="recoveryPanel"
>
{content}
</SettingsSection>
);
}
interface SubheaderProps {
/**
* The state of the recovery panel.
*/
state: State;
}
/**
* The subheader for the recovery panel.
*/
function Subheader({ state }: SubheaderProps): JSX.Element {
// If the secrets are not cached, we display a warning message.
if (state !== "secrets_not_cached") return <>{_t("settings|encryption|recovery|description")}</>;
return (
<SettingsSubheader
label={_t("settings|encryption|recovery|description")}
state="error"
stateMessage={_t("settings|encryption|recovery|key_storage_warning")}
/>
);
}

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