Compare commits

...

41 Commits

Author SHA1 Message Date
Half-Shot
9623639b08 Replace use of Field with EditInPlace 2025-02-14 19:04:28 +00:00
Half-Shot
976a8f44bc lint 2025-02-14 17:33:18 +00:00
Half-Shot
9cb55970d3 Refactor Apperance advanced features to not rely on tooltips. 2025-02-14 17:26:11 +00:00
Half-Shot
d82029d0c1 Set ID Server input now always shows a tooltip on error. 2025-02-14 17:25:50 +00:00
Half-Shot
c1df3d1511 When forceTooltipVisible is set, the tooltip should always be visible. 2025-02-14 17:24:53 +00:00
ElementRobot
f9a85d37fa [create-pull-request] automated change (#29253)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-02-14 06:14:47 +00:00
Florian D
2abd5342c2 New room list: add search section (#29251)
* feat(new room list): move `RoomListView` to its own folder and add styling

* feat(new room list): add search section

* test(new room list): add tests for `RoomListSearch`

* test(new room list): add tests for `RoomListView`

* test(e2e): add method to close notification toast to `ElementAppPage`

* test(e2e): add tests for the search section

* test(e2e): add tests for the room list view

* refactor: use Flex component

* fix: loop icon size in search button

* refactor: remove `focus_room_filter` listener
2025-02-13 15:49:09 +00:00
Florian D
85f80b1d0a Replace focus_room_filter dispatch by Action.OpenSpotlight (#29259)
* refactor(room search): replace `focus_room_filter` dispatch by `Action.OpenSpotlight`

* test(LoggedInView): add test to Ctrl+k shortcut
2025-02-13 15:18:41 +00:00
Florian D
4b9382f888 New room list: hide favourites and people meta spaces (#29241)
* feat(new room list)!: hide Favourites and People meta spaces when the new room list is enabled

* test(space store): add testcase for new labs flag

* feat(quick settings): hide pin to sidebar and more options and add extra margin
2025-02-13 10:53:51 +00:00
R Midhun Suresh
03a5ee1c5b New Room List: Create new labs flag (#29239)
* Create new labs flag

* Render empty room list view

* Reload on flag change

* Rename RoomList.tsx to LegacyRoomList.tsx

and rename NewRoomListView.tsx to RoomListView.tsx

* Update labs.md
2025-02-12 12:14:18 +00:00
ElementRobot
902146a829 [create-pull-request] automated change (#29248)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-02-12 06:20:49 +00:00
ElementRobot
43e918b71e [create-pull-request] automated change (#29247)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-02-12 06:15:32 +00:00
David Baker
53c97dfa50 Don't reload roomview on offline connectivity check (#29243)
* Don't reload roomview on offline connectivity check

Doesn't look like this was a regression as far as I can see, but
you did have to switch rooms while offline for it to start happening.

There's no use reloading the room until we're online again.

Fixes https://github.com/element-hq/element-web/issues/29072

* Add regression test

* Move it down the file to avoid changing the snapshots
2025-02-11 20:34:32 +00:00
Richard van der Hoff
f7b010a0b3 Clean up unused code in CreateSecretStorageDialog (#29205)
* CreateSecretStorageDialog: remove unused state  `accountPasswordCorrect`

This was never set to anything other than `null`, and never read.

* CreateSecretStorageDialog: remove unused prop `accountPassword`

This was never set, so we may as well remove it.

* CreateSecretStorageDialog: remove unused state `accountPassword`

This is now no longer set to anything other than `""`.

* CreateSecretStorageDialog: remove unused state `canUploadKeysWithPasswordOnly`

This is no longer read, so let's remove the code that populates it.

* CreateSecretStorageDialog: remove unused prop `hasCancel`

This is never set, so may as well remove

* Update src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx
2025-02-11 15:22:01 +00:00
Michael Telatynski
161323b595 Fix permissions 2025-02-11 15:10:59 +00:00
Terence Eden
8bf3ec29b9 Stop URl preview from covering message box (#29215)
* Stop URl preview from covering message box

Fixes #23874 by adding a bit of padding.

1em should be sufficient to prevent the browser's URl preview from covering the entry box.

* test: update timeline screenshots

* test: update test, fewer messages are displayed

---------

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
Co-authored-by: Florian D <florianduros@element.io>
2025-02-11 14:51:36 +00:00
Michael Telatynski
039b95eba0 Ship Docker image also to GHCR.io (#29240)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-02-11 14:51:19 +00:00
RiotRobot
6b80d3fca0 Reset matrix-js-sdk back to develop branch 2025-02-11 14:35:37 +00:00
RiotRobot
784abbbe14 Merge branch 'master' into develop 2025-02-11 14:35:19 +00:00
RiotRobot
4d55e8f433 v1.11.92 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
Michael Telatynski
6fa8032caa Respect user's 12/24 hour preference consistently (#29237)
* Respect user's 12/24 hour preference consistently

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

* Update test

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-02-11 14:16:59 +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
Richard van der Hoff
f2fae82e32 Log when we show, and hide, encryption setup toasts (#29235)
It's currently hard to debug when someone sees or hides one of these
toasts. Lets's add some logging.
2025-02-11 09:44:48 +00:00
Robin
ef69c0ddc7 Restore the accessibility role on call views (#29225)
This was mistakenly removed in a370a5cfa4. You can tell it was unintentional because the 'role' variable was just left unused.
2025-02-11 08:28:29 +00:00
ElementRobot
bc7fe25974 [create-pull-request] automated change (#29236)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-02-11 06:14:04 +00:00
Florian D
426a2066d9 test(backup mas): use checkDeviceIsConnectedKeyBackup instead at looking at the Security & Privacy tab (#29146)
* test(backup mas): use `checkDeviceIsConnectedKeyBackup` instead at looking at the *Security & Privacy* tab

* doc(backup mas): fix comment
2025-02-10 17:44:56 +00:00
Florian D
047e8e8a9c Rename "security key" into "recovery key" (#29217)
* feat(crypto): rename "security key" into "recovery key" in lang file

* test(crypto): rename "security key" into "recovery key" in test files

* test(e2e crypto): rename "security key" into "recovery key" in test files

* doc(crypto): rename "security key" into "recovery key"
2025-02-10 16:52:39 +00:00
Máté Gyöngyösi
4de9fe60ae Change GoToHome keyboard shortcut to CtrlAlt/ShiftH (#28577)
Co-authored-by: Florian D <florianduros@element.io>
2025-02-10 16:16:34 +00:00
R Midhun Suresh
52b42c0b1c Add new verification section to user profile (#29200)
* Create new verification section

* Remove old code and use new VerificationSection

* Add styling and translation

* Fix tests

* Remove dead code

* Fix broken test

* Remove imports

* Remove console.log

* Update snapshots

* Fix broken tests

* Fix lint

* Make badge expand with content

* Remove unused code
2025-02-10 11:22:58 +00:00
ElementRobot
bb8b4d7991 [create-pull-request] automated change (#29224)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-02-10 06:19:28 +00:00
ElementRobot
ec994884fb [create-pull-request] automated change (#29218)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-02-08 06:13:54 +00:00
Florian D
4a8ba810a9 Encryption tab: display correct encryption panel when user cancels the reset identity flow (#29216)
* fix(encryption settings): check encryption state when user cancels the reset identity flow

* test(encryption settings): add test to check encryption state when user cancels the reset identity flow
2025-02-07 13:57:02 +00:00
Michael Telatynski
40a6c69f1a Close stale PRs after 180 days (#29212)
* Close stale PRs after 180 days

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

* Update triage-stale.yml

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-02-07 13:44:26 +00:00
ElementRobot
eed6b6df12 [create-pull-request] automated change (#29157)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-02-07 12:49:08 +00:00
Florian D
7b3ce5d9b2 e2e test: by default the bot should not use a passphrase to create the recovery key (#29214)
* test(crypto): by default do not use a passphrase to create the recovery key

* test(crypto): update tests
2025-02-07 09:39:36 +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
Michael Telatynski
4a231c6450 Initial support for runtime modules (#29104)
* Initial runtime Modules work

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>

* Iterate

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

* Iterate

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

* Comments

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-02-06 23:54:18 +00:00
R Midhun Suresh
3c690e685a Add code_style, developer_guide and fix CONTRIBUTING.md (#29210)
* Remove reference to matrix-js-sdk in code_style.md

* Absorb exisiting documentation from matrix-react-sdk

* Crete a separate developer guide

* Remove sign-off from CONTRIBUTING.md

Since sign-off is irrelevant to element-web repo with the introduction
of CLA.

* Link to the new docs in README

* Elaborate on the rule

* Fix lint
2025-02-06 11:02:53 +00:00
RiotRobot
0a8393c9e1 v1.11.92-rc.0 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
163 changed files with 2393 additions and 2498 deletions

View File

@@ -1,4 +1,4 @@
name: Dockerhub
name: Docker
on:
workflow_dispatch: {}
push:
@@ -15,6 +15,7 @@ jobs:
environment: dockerhub
permissions:
id-token: write # needed for signing the images with GitHub OIDC Token
packages: write # needed for publishing packages to GHCR
steps:
- uses: actions/checkout@v4
with:
@@ -37,12 +38,20 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5
with:
images: |
vectorim/element-web
ghcr.io/element-hq/element-web
tags: |
type=ref,event=branch
type=ref,event=tag

View File

@@ -50,7 +50,7 @@ jobs:
permissions:
checks: read
steps:
- name: Wait for dockerhub
- name: Wait for docker build
uses: t3chguy/wait-on-check-action@18541021811b56544d90e0f073401c2b99e249d6 # fork
with:
ref: master

View File

@@ -1,21 +0,0 @@
name: Close stale flaky issues
on:
workflow_dispatch: {}
schedule:
- cron: "30 1 * * *"
permissions: {}
jobs:
close:
runs-on: ubuntu-24.04
permissions:
actions: write
issues: write
steps:
- uses: actions/stale@v9
with:
only-labels: "Z-Flaky-Test"
days-before-stale: 14
days-before-close: 0
close-issue-message: "This flaky test issue has not been updated in 14 days. It is being closed as presumed resolved."
exempt-issue-labels: "Z-Flaky-Test-Disabled"
operations-per-run: 100

27
.github/workflows/triage-stale.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Close stale issues & PRs
on:
workflow_dispatch: {}
schedule:
- cron: "30 1 * * *"
permissions: {}
jobs:
close:
runs-on: ubuntu-24.04
permissions:
actions: write
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:
operations-per-run: 100
# Flaky test issue closing
only-issue-labels: "Z-Flaky-Test"
days-before-issue-stale: 14
days-before-issue-close: 0
close-issue-message: "This flaky test issue has not been updated in 14 days. It is being closed as presumed resolved."
exempt-issue-labels: "Z-Flaky-Test-Disabled"
# Stale PR closing
days-before-pr-stale: 180
days-before-pr-close: 0
close-pr-message: "This PR has been automatically closed because it has been stale for 180 days. If you wish to continue working on this PR, please ping a maintainer to reopen it."

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

View File

@@ -189,89 +189,6 @@ give away to contributors - if you feel that Matrix-branded apparel is missing
from your life, please mail us your shipping address to matrix at matrix.org
and we'll try to fix it :)
## Sign off
In order to have a concrete record that your contribution is intentional
and you agree to license it under the same terms as the project's license, we've
adopted the same lightweight approach that the Linux Kernel
(https://www.kernel.org/doc/html/latest/process/submitting-patches.html), Docker
(https://github.com/docker/docker/blob/master/CONTRIBUTING.md), and many other
projects use: the DCO (Developer Certificate of Origin:
http://developercertificate.org/). This is a simple declaration that you wrote
the contribution or otherwise have the right to contribute it to Matrix:
```
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
660 York Street, Suite 102,
San Francisco, CA 94110 USA
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
```
If you agree to this for your contribution, then all that's needed is to
include the line in your commit or pull request comment:
```
Signed-off-by: Your Name <your@email.example.org>
```
We accept contributions under a legally identifiable name, such as your name on
government documentation or common-law names (names claimed by legitimate usage
or repute). Unfortunately, we cannot accept anonymous contributions at this
time.
Git allows you to add this signoff automatically when using the `-s` flag to
`git commit`, which uses the name and email set in your `user.name` and
`user.email` git configs.
If you forgot to sign off your commits before making your pull request and are
on Git 2.17+ you can mass signoff using rebase:
```
git rebase --signoff origin/develop
```
## Private sign off
If you would like to provide your legal name privately to the Matrix.org
Foundation (instead of in a public commit or comment), you can do so by emailing
your legal name and a link to the pull request to dco@matrix.org. It helps to
include "sign off" or similar in the subject line. You will then be instructed
further.
Once private sign off is complete, doing so for future contributions will not
be required.
# Review expectations
See https://github.com/element-hq/element-meta/wiki/Review-process

120
README.md
View File

@@ -182,123 +182,11 @@ Dockerfile.
# Development
Before attempting to develop on Element you **must** read the [developer guide
for `matrix-react-sdk`](https://github.com/matrix-org/matrix-react-sdk#developer-guide), which
also defines the design, architecture and style for Element too.
Please read through the following:
Read the [Choosing an issue](docs/choosing-an-issue.md) page for some guidance
about where to start. Before starting work on a feature, it's best to ensure
your plan aligns well with our vision for Element. Please chat with the team in
[#element-dev:matrix.org](https://matrix.to/#/#element-dev:matrix.org) before
you start so we can ensure it's something we'd be willing to merge.
You should also familiarise yourself with the ["Here be Dragons" guide
](https://docs.google.com/document/d/12jYzvkidrp1h7liEuLIe6BMdU0NUjndUYI971O06ooM)
to the tame & not-so-tame dragons (gotchas) which exist in the codebase.
The idea of Element is to be a relatively lightweight "skin" of customisations on
top of the underlying `matrix-react-sdk`. `matrix-react-sdk` provides both the
higher and lower level React components useful for building Matrix communication
apps using React.
Please note that Element is intended to run correctly without access to the public
internet. So please don't depend on resources (JS libs, CSS, images, fonts)
hosted by external CDNs or servers but instead please package all dependencies
into Element itself.
# Setting up a dev environment
Much of the functionality in Element is actually in the `matrix-js-sdk` module.
It is possible to set these up in a way that makes it easy to track the `develop` branches
in git and to make local changes without having to manually rebuild each time.
First clone and build `matrix-js-sdk`:
```bash
git clone https://github.com/matrix-org/matrix-js-sdk.git
pushd matrix-js-sdk
yarn link
yarn install
popd
```
Clone the repo and switch to the `element-web` directory:
```bash
git clone https://github.com/element-hq/element-web.git
cd element-web
```
Configure the app by copying `config.sample.json` to `config.json` and
modifying it. See the [configuration docs](docs/config.md) for details.
Finally, build and start Element itself:
```bash
yarn link matrix-js-sdk
yarn install
yarn start
```
Wait a few seconds for the initial build to finish; you should see something like:
```
[element-js] <s> [webpack.Progress] 100%
[element-js]
[element-js] 「wdm」: 1840 modules
[element-js] 「wdm」: Compiled successfully.
```
Remember, the command will not terminate since it runs the web server
and rebuilds source files when they change. This development server also
disables caching, so do NOT use it in production.
Open <http://127.0.0.1:8080/> in your browser to see your newly built Element.
**Note**: The build script uses inotify by default on Linux to monitor directories
for changes. If the inotify limits are too low your build will fail silently or with
`Error: EMFILE: too many open files`. To avoid these issues, we recommend a watch limit
of at least `128M` and instance limit around `512`.
You may be interested in issues [#15750](https://github.com/element-hq/element-web/issues/15750) and
[#15774](https://github.com/element-hq/element-web/issues/15774) for further details.
To set a new inotify watch and instance limit, execute:
```
sudo sysctl fs.inotify.max_user_watches=131072
sudo sysctl fs.inotify.max_user_instances=512
sudo sysctl -p
```
If you wish, you can make the new limits permanent, by executing:
```
echo fs.inotify.max_user_watches=131072 | sudo tee -a /etc/sysctl.conf
echo fs.inotify.max_user_instances=512 | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
```
---
When you make changes to `matrix-js-sdk` they should be automatically picked up by webpack and built.
If any of these steps error with, `file table overflow`, you are probably on a mac
which has a very low limit on max open files. Run `ulimit -Sn 1024` and try again.
You'll need to do this in each new terminal you open before building Element.
## Running the tests
There are a number of application-level tests in the `tests` directory; these
are designed to run with Jest and JSDOM. To run them
```
yarn test
```
### End-to-End tests
See [matrix-react-sdk](https://github.com/matrix-org/matrix-react-sdk/#end-to-end-tests) for how to run the end-to-end tests.
1. [Developer guide](./developer_guide.md)
2. [Code style](./code_style.md)
3. [Contribution guide](./CONTRIBUTING.md)
# Translations

View File

@@ -5,15 +5,6 @@ adjacent to. As of writing, these are:
- element-desktop
- element-web
- matrix-js-sdk
Other projects might extend this code style for increased strictness. For example, matrix-events-sdk
has stricter code organization to reduce the maintenance burden. These projects will declare their code
style within their own repos.
Note that some requirements will be layer-specific. Where the requirements don't make sense for the
project, they are used to the best of their ability, used in spirit, or ignored if not applicable,
in that order.
## Guiding principles
@@ -234,17 +225,19 @@ Unless otherwise specified, the following applies to all code:
Inheriting all the rules of TypeScript, the following additionally apply:
1. Types for lifecycle functions are not required (render, componentDidMount, and so on).
2. Class components must always have a `Props` interface declared immediately above them. It can be
1. Component source files are named with upper camel case (e.g. views/rooms/EventTile.js)
2. They are organised in a typically two-level hierarchy - first whether the component is a view or a structure, and then a broad functional grouping (e.g. 'rooms' here)
3. Types for lifecycle functions are not required (render, componentDidMount, and so on).
4. Class components must always have a `Props` interface declared immediately above them. It can be
empty if the component accepts no props.
3. Class components should have an `State` interface declared immediately above them, but after `Props`.
4. Props and State should not be exported. Use `React.ComponentProps<typeof ComponentNameHere>`
5. Class components should have an `State` interface declared immediately above them, but after `Props`.
6. Props and State should not be exported. Use `React.ComponentProps<typeof ComponentNameHere>`
instead.
5. One component per file, except when a component is a utility component specifically for the "primary"
7. One component per file, except when a component is a utility component specifically for the "primary"
component. The utility component should not be exported.
6. Exported constants, enums, interfaces, functions, etc must be separate from files containing components
8. Exported constants, enums, interfaces, functions, etc must be separate from files containing components
or stores.
7. Stores should use a singleton pattern with a static instance property:
9. Stores should use a singleton pattern with a static instance property:
```typescript
class FooStore {
@@ -261,44 +254,41 @@ Inheriting all the rules of TypeScript, the following additionally apply:
}
```
8. Stores must support using an alternative MatrixClient and dispatcher instance.
9. Utilities which require JSX must be split out from utilities which do not. This is to prevent import
cycles during runtime where components accidentally include more of the app than they intended.
10. Interdependence between stores should be kept to a minimum. Break functions and constants out to utilities
10. Stores must support using an alternative MatrixClient and dispatcher instance.
11. Utilities which require JSX must be split out from utilities which do not. This is to prevent import
cycles during runtime where components accidentally include more of the app than they intended.
12. Interdependence between stores should be kept to a minimum. Break functions and constants out to utilities
if at all possible.
11. A component should only use CSS class names in line with the component name.
13. A component should only use CSS class names in line with the component name.
1. When knowingly using a class name from another component, document it with a [comment](#comments).
12. Curly braces within JSX should be padded with a space, however properties on those components should not.
14. Curly braces within JSX should be padded with a space, however properties on those components should not.
See above code example.
13. Functions used as properties should either be defined on the class or stored in a variable. They should not
15. Functions used as properties should either be defined on the class or stored in a variable. They should not
be inline unless mocking/short-circuiting the value.
14. Prefer hooks (functional components) over class components. Be consistent with the existing area if unsure
16. Prefer hooks (functional components) over class components. Be consistent with the existing area if unsure
which should be used.
1. Unless the component is considered a "structure", in which case use classes.
15. Write more views than structures. Structures are chunks of functionality like MatrixChat while views are
17. Write more views than structures. Structures are chunks of functionality like MatrixChat while views are
isolated components.
16. Components should serve a single, or near-single, purpose.
17. Prefer to derive information from component properties rather than establish state.
18. Do not use `React.Component::forceUpdate`.
18. Components should serve a single, or near-single, purpose.
19. Prefer to derive information from component properties rather than establish state.
20. Do not use `React.Component::forceUpdate`.
## Stylesheets (\*.pcss = PostCSS + Plugins)
Note: We use PostCSS + some plugins to process our styles. It looks like SCSS, but actually it is not.
1. Class names must be prefixed with "mx\_".
2. Class names must denote the component which defines them, followed by any context.
The context is not further specified here in terms of meaning or syntax.
Use whatever is appropriate for your implementation use case.
Some examples:
1. `mx_MyFoo`
2. `mx_MyFoo_avatar`
3. `mx_MyFoo_avatarUser`
4. `mx_MyFoo_avatar--user`
3. Use the `$font` variables instead of manual values.
4. Keep indentation/nesting to a minimum. Maximum suggested nesting is 5 layers.
5. Use the whole class name instead of shortcuts:
1. The view's CSS file MUST have the same name as the component (e.g. `view/rooms/_MessageTile.css` for `MessageTile.tsx` component).
2. Per-view CSS is optional - it could choose to inherit all its styling from the context of the rest of the app, although this is unusual.
3. Class names must be prefixed with "mx\_".
4. Class names must strictly denote the component which defines them.
For example: `mx_MyFoo` for `MyFoo` component.
5. Class names for DOM elements within a view which aren't components are named by appending a lower camel case identifier to the view's class name - e.g. .mx_MyFoo_randomDiv is how you'd name the class of an arbitrary div within the MyFoo view.
6. Use the `$font` variables instead of manual values.
7. Keep indentation/nesting to a minimum. Maximum suggested nesting is 5 layers.
8. Use the whole class name instead of shortcuts:
```scss
.mx_MyFoo {
@@ -309,7 +299,7 @@ Note: We use PostCSS + some plugins to process our styles. It looks like SCSS, b
}
```
6. Break multiple selectors over multiple lines this way:
9. Break multiple selectors over multiple lines this way:
```scss
.mx_MyFoo,
@@ -319,9 +309,9 @@ Note: We use PostCSS + some plugins to process our styles. It looks like SCSS, b
}
```
7. Non-shared variables should use $lowerCamelCase. Shared variables use $dashed-naming.
8. Overrides to Z indexes, adjustments of dimensions/padding with pixels, and so on should all be
[documented](#comments) for what the values mean:
10. Non-shared variables should use $lowerCamelCase. Shared variables use $dashed-naming.
11. Overrides to Z indexes, adjustments of dimensions/padding with pixels, and so on should all be
[documented](#comments) for what the values mean:
```scss
.mx_MyFoo {
@@ -331,7 +321,9 @@ Note: We use PostCSS + some plugins to process our styles. It looks like SCSS, b
}
```
9. Avoid the use of `!important`. If `!important` is necessary, add a [comment](#comments) explaining why.
12. Avoid the use of `!important`. If `!important` is necessary, add a [comment](#comments) explaining why.
13. The CSS for a component can override the rules for child components. For instance, .mxRoomList .mx_RoomTile {} would be the selector to override styles of RoomTiles when viewed in the context of a RoomList view. Overrides must be scoped to the View's CSS class - i.e. don't just define .mx_RoomTile {} in RoomList.css - only RoomTile.css is allowed to define its own CSS. Instead, say .mx_RoomList .mx_RoomTile {} to scope the override only to the context of RoomList views. N.B. overrides should be relatively rare as in general CSS inheritance should be enough.
14. Components should render only within the bounding box of their outermost DOM element. Page-absolute positioning and negative CSS margins and similar are generally not cool and stop the component from being reused easily in different places.
## Tests

126
developer_guide.md Normal file
View File

@@ -0,0 +1,126 @@
# Developer Guide
## Development
Read the [Choosing an issue](docs/choosing-an-issue.md) page for some guidance
about where to start. Before starting work on a feature, it's best to ensure
your plan aligns well with our vision for Element. Please chat with the team in
[#element-dev:matrix.org](https://matrix.to/#/#element-dev:matrix.org) before
you start so we can ensure it's something we'd be willing to merge.
You should also familiarise yourself with the ["Here be Dragons" guide
](https://docs.google.com/document/d/12jYzvkidrp1h7liEuLIe6BMdU0NUjndUYI971O06ooM)
to the tame & not-so-tame dragons (gotchas) which exist in the codebase.
Please note that Element is intended to run correctly without access to the public
internet. So please don't depend on resources (JS libs, CSS, images, fonts)
hosted by external CDNs or servers but instead please package all dependencies
into Element itself.
## Setting up a dev environment
Much of the functionality in Element is actually in the `matrix-js-sdk` module.
It is possible to set these up in a way that makes it easy to track the `develop` branches
in git and to make local changes without having to manually rebuild each time.
First clone and build `matrix-js-sdk`:
```bash
git clone https://github.com/matrix-org/matrix-js-sdk.git
pushd matrix-js-sdk
yarn link
yarn install
popd
```
Clone the repo and switch to the `element-web` directory:
```bash
git clone https://github.com/element-hq/element-web.git
cd element-web
```
Configure the app by copying `config.sample.json` to `config.json` and
modifying it. See the [configuration docs](docs/config.md) for details.
Finally, build and start Element itself:
```bash
yarn link matrix-js-sdk
yarn install
yarn start
```
Wait a few seconds for the initial build to finish; you should see something like:
```
[element-js] <s> [webpack.Progress] 100%
[element-js]
[element-js] 「wdm」: 1840 modules
[element-js] 「wdm」: Compiled successfully.
```
Remember, the command will not terminate since it runs the web server
and rebuilds source files when they change. This development server also
disables caching, so do NOT use it in production.
Open <http://127.0.0.1:8080/> in your browser to see your newly built Element.
**Note**: The build script uses inotify by default on Linux to monitor directories
for changes. If the inotify limits are too low your build will fail silently or with
`Error: EMFILE: too many open files`. To avoid these issues, we recommend a watch limit
of at least `128M` and instance limit around `512`.
You may be interested in issues [#15750](https://github.com/element-hq/element-web/issues/15750) and
[#15774](https://github.com/element-hq/element-web/issues/15774) for further details.
To set a new inotify watch and instance limit, execute:
```
sudo sysctl fs.inotify.max_user_watches=131072
sudo sysctl fs.inotify.max_user_instances=512
sudo sysctl -p
```
If you wish, you can make the new limits permanent, by executing:
```
echo fs.inotify.max_user_watches=131072 | sudo tee -a /etc/sysctl.conf
echo fs.inotify.max_user_instances=512 | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
```
---
When you make changes to `matrix-js-sdk` they should be automatically picked up by webpack and built.
If any of these steps error with, `file table overflow`, you are probably on a mac
which has a very low limit on max open files. Run `ulimit -Sn 1024` and try again.
You'll need to do this in each new terminal you open before building Element.
## Running the tests
There are a number of application-level tests in the `tests` directory; these
are designed to run with Jest and JSDOM. To run them
```
yarn test
```
### End-to-End tests
See [matrix-react-sdk](https://github.com/matrix-org/matrix-react-sdk/#end-to-end-tests) for how to run the end-to-end tests.
## General github guidelines
1. **Pull requests must only be filed against the `develop` branch.**
2. Try to keep your pull requests concise. Split them up if necessary.
3. Ensure that you provide a description that explains the fix/feature and its intent.
## Adding new code
New code should be committed as follows:
- All new components: https://github.com/element-hq/element-web/tree/develop/src/components
- CSS: https://github.com/element-hq/element-web/tree/develop/res/css
- Theme specific CSS & resources: https://github.com/element-hq/element-web/tree/develop/res/themes

View File

@@ -155,7 +155,7 @@ complete re-branding/private labeling, a more personalised experience can be ach
3. `show_once`: Optional. If true then the notice will only be shown once per device.
18. `help_url`: The URL to point users to for help with the app, defaults to `https://element.io/help`.
19. `help_encryption_url`: The URL to point users to for help with encryption, defaults to `https://element.io/help#encryption`.
20. `force_verification`: If true, users must verify new logins (eg. with another device / their security key)
20. `force_verification`: If true, users must verify new logins (eg. with another device / their recovery key)
### `desktop_builds` and `mobile_builds`
@@ -592,3 +592,4 @@ The following are undocumented or intended for developer use only.
2. `sync_timeline_limit`
3. `dangerously_allow_unsafe_and_insecure_passwords`
4. `latex_maths_delims`: An optional setting to override the default delimiters used for maths parsing. See https://github.com/matrix-org/matrix-react-sdk/pull/5939 for details. Only used when `feature_latex_maths` is enabled.
5. `modules`: An optional list of modules to load. This is used for testing and development purposes only.

View File

@@ -112,3 +112,7 @@ Unreliable in encrypted rooms.
## Knock rooms (`feature_ask_to_join`) [In Development]
Enables knock feature for rooms. This allows users to ask to join a room.
## New room list (`feature_new_room_list`) [In Development]
Enable the new room list that is currently in development.

View File

@@ -128,7 +128,7 @@ flowchart TD
subgraph Deploying
D1[\Deploy staging.element.io/]
D2[\Check dockerhub/]
D2[\Check docker build/]
D3[\Deploy app.element.io/]
D4[\Check desktop package/]
@@ -213,10 +213,10 @@ switched back to the version of the dependency from the master branch to not lea
# Deploying
We ship the SDKs to npm, this happens as part of the release process.
We ship Element Web to dockerhub, `*.element.io`, and packages.element.io.
We ship Element Web to dockerhub, ghcr.io, `*.element.io`, and packages.element.io.
We ship Element Desktop to packages.element.io.
- [ ] Check that element-web has shipped to dockerhub
- [ ] Check that element-web has shipped to dockerhub & ghcr.io
- [ ] Check that the staging [deployment](https://github.com/element-hq/element-web/actions/workflows/deploy.yml) has completed successfully
- [ ] Test staging.element.io

View File

@@ -38,6 +38,8 @@ const config: Config = {
"^!!raw-loader!.*": "jest-raw-loader",
"recorderWorkletFactory": "<rootDir>/__mocks__/empty.js",
"^fetch-mock$": "<rootDir>/node_modules/fetch-mock",
// Requires ESM which is incompatible with our current Jest setup
"^@element-hq/element-web-module-api$": "<rootDir>/__mocks__/empty.js",
},
transformIgnorePatterns: ["/node_modules/(?!(mime|matrix-js-sdk)).+$"],
collectCoverageFrom: [

View File

@@ -80,6 +80,7 @@
},
"dependencies": {
"@babel/runtime": "^7.12.5",
"@element-hq/element-web-module-api": "^0.1.1",
"@fontsource/inconsolata": "^5",
"@fontsource/inter": "^5",
"@formatjs/intl-segmenter": "^11.5.7",

View File

@@ -11,6 +11,7 @@ import { registerAccountMas } from "../oidc";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { TestClientServerAPI } from "../csAPI";
import { masHomeserver } from "../../plugins/homeserver/synapse/masHomeserver.ts";
import { checkDeviceIsConnectedKeyBackup } from "./utils";
// These tests register an account with MAS because then we go through the "normal" registration flow
// and crypto gets set up. Using the 'user' fixture create a user and synthesizes an existing login,
@@ -24,8 +25,11 @@ test.describe("Encryption state after registration", () => {
await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailpitClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!");
await app.settings.openUserSettings("Security & Privacy");
await expect(page.getByText("This session is backing up your keys.")).toBeVisible();
// Wait for the ui to load
await expect(page.locator(".mx_MatrixChat")).toBeVisible();
// Recovery is not set up yet
await checkDeviceIsConnectedKeyBackup(app, "1", true, false);
});
test("user is prompted to set up recovery", async ({ page, mailpitClient, app }, testInfo) => {

View File

@@ -58,8 +58,8 @@ test.describe("Backups", () => {
// Create another
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Security Key" })).toBeVisible();
await currentDialogLocator.getByLabel("Security Key").fill(securityKey);
await expect(currentDialogLocator.getByRole("heading", { name: "Recovery Key" })).toBeVisible();
await currentDialogLocator.getByLabel("Recovery Key").fill(securityKey);
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
// Should be successful
@@ -90,8 +90,8 @@ test.describe("Backups", () => {
// Try to create another
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Security Key" })).toBeVisible();
// But cancel the security key dialog, to simulate not having the secret storage passphrase
await expect(currentDialogLocator.getByRole("heading", { name: "Recovery Key" })).toBeVisible();
// But cancel the recovery key dialog, to simulate not having the secret storage passphrase
await currentDialogLocator.getByTestId("dialog-cancel-button").click();
await expect(currentDialogLocator.getByRole("heading", { name: "Starting backup…" })).toBeVisible();

View File

@@ -186,7 +186,7 @@ test.describe("Cryptography", function () {
await page.getByRole("button", { name: "Clear cross-signing keys" }).click();
// Enter the 4S key
await page.getByPlaceholder("Security Key").fill(secretStorageKey);
await page.getByPlaceholder("Recovery Key").fill(secretStorageKey);
await page.getByRole("button", { name: "Continue" }).click();
// Enter the password

View File

@@ -6,21 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type Locator, type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { viewRoomSummaryByName } from "../right-panel/utils";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { completeCreateSecretStorageDialog, createBot, logIntoElement } from "./utils.ts";
import { type Client } from "../../pages/client.ts";
const ROOM_NAME = "Test room";
const NAME = "Alice";
function getMemberTileByName(page: Page, name: string): Locator {
return page.locator(`.mx_MemberTileView, [title="${name}"]`);
}
test.use({
displayName: NAME,
synapseConfig: {
@@ -70,23 +62,6 @@ test.describe("Dehydration", () => {
// device.
const sessionsTab = await app.settings.openUserSettings("Sessions");
await expect(sessionsTab.getByText("Dehydrated device")).not.toBeVisible();
await app.settings.closeDialog();
// now check that the user info right-panel shows the dehydrated device
// as a feature rather than as a normal device
await app.client.createRoom({ name: ROOM_NAME });
await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.locator(".mx_RightPanel").getByRole("menuitem", { name: "People" }).click();
await expect(page.locator(".mx_MemberListView")).toBeVisible();
await getMemberTileByName(page, NAME).click();
await page.locator(".mx_UserInfo_devices .mx_UserInfo_expand").click();
await expect(page.locator(".mx_UserInfo_devices").getByText("Offline device enabled")).toBeVisible();
await expect(page.locator(".mx_UserInfo_devices").getByText("Dehydrated device")).not.toBeVisible();
});
test("Reset recovery key during login re-creates dehydrated device", async ({

View File

@@ -29,7 +29,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
let expectedBackupVersion: string;
test.beforeEach(async ({ page, homeserver, credentials }) => {
const res = await createBot(page, homeserver, credentials);
const res = await createBot(page, homeserver, credentials, true);
aliceBotClient = res.botClient;
expectedBackupVersion = res.expectedBackupVersion;
});
@@ -119,7 +119,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
await logIntoElement(page, credentials);
// Select the security phrase
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key or Phrase" }).click();
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key or Phrase" }).click();
// Fill the passphrase
const dialog = page.locator(".mx_Dialog");
@@ -136,15 +136,15 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
});
test("Verify device with Security Key during login", async ({ page, app, credentials, homeserver }) => {
test("Verify device with Recovery Key during login", async ({ page, app, credentials, homeserver }) => {
await logIntoElement(page, credentials);
// Select the security phrase
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key or Phrase" }).click();
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key or Phrase" }).click();
// Fill the security key
// Fill the recovery key
const dialog = page.locator(".mx_Dialog");
await dialog.getByRole("button", { name: "use your Security Key" }).click();
await dialog.getByRole("button", { name: "use your Recovery Key" }).click();
const aliceRecoveryKey = await aliceBotClient.getRecoveryKey();
await dialog.locator("#mx_securityKey").fill(aliceRecoveryKey.encodedPrivateKey);
await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();

View File

@@ -17,6 +17,7 @@ import {
logIntoElement,
logOutOfElement,
verify,
waitForDevices,
} from "./utils";
import { bootstrapCrossSigningForClient } from "../../pages/client.ts";
import { type ElementAppPage } from "../../pages/ElementAppPage.ts";
@@ -144,25 +145,8 @@ test.describe("Cryptography", function () {
// bob deletes his second device
await bobSecondDevice.evaluate((cli) => cli.logout(true));
// wait for the logout to propagate. Workaround for https://github.com/vector-im/element-web/issues/26263 by repeatedly closing and reopening Bob's user info.
async function awaitOneDevice(iterations = 1) {
const rightPanel = page.locator(".mx_RightPanel");
await rightPanel.getByTestId("base-card-back-button").click();
await rightPanel.getByText("Bob").click();
const sessionCountText = await rightPanel
.locator(".mx_UserInfo_devices")
.getByText(" session", { exact: false })
.textContent();
// cf https://github.com/vector-im/element-web/issues/26279: Element-R uses the wrong text here
if (sessionCountText != "1 session" && sessionCountText != "1 verified session") {
if (iterations >= 10) {
throw new Error(`Bob still has ${sessionCountText} after 10 iterations`);
}
await awaitOneDevice(iterations + 1);
}
}
await awaitOneDevice();
// wait for the logout to propagate.
await waitForDevices(app, bob.credentials.userId, 1);
// close and reopen the room, to get the shield to update.
await app.viewRoomByName("Bob");
@@ -285,11 +269,7 @@ test.describe("Cryptography", function () {
// Workaround for https://github.com/element-hq/element-web/issues/28640:
// make sure that Alice has seen Bob's identity before she goes offline. We do this by opening
// his user info.
await app.toggleRoomInfoPanel();
const rightPanel = page.locator(".mx_RightPanel");
await rightPanel.getByRole("menuitem", { name: "People" }).click();
await rightPanel.getByRole("button", { name: bob.credentials!.userId }).click();
await expect(rightPanel.locator(".mx_UserInfo_devices")).toContainText("1 session");
await waitForDevices(app, bob.credentials.userId, 1);
// Our app is blocked from syncing while Bob sends his messages.
await app.client.network.goOffline();

View File

@@ -34,9 +34,8 @@ test.describe("Key storage out of sync toast", () => {
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("textbox", { name: "Recovery Key" }).fill(recoveryKey.encodedPrivateKey);
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.getByRole("button", { name: "Enter recovery key" })).not.toBeVisible();

View File

@@ -8,9 +8,8 @@ Please see LICENSE files in the repository root for full details.
import { type Preset, type Visibility } from "matrix-js-sdk/src/matrix";
import type { Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { doTwoWaySasVerification, awaitVerifier } from "./utils";
import { doTwoWaySasVerification, awaitVerifier, waitForDevices } from "./utils";
import { type Client } from "../../pages/client";
test.describe("User verification", () => {
@@ -33,13 +32,17 @@ test.describe("User verification", () => {
});
test("can receive a verification request when there is no existing DM", async ({
app,
page,
bot: bob,
user: aliceCredentials,
toasts,
room: { roomId: dmRoomId },
}) => {
await waitForDeviceKeys(page);
await waitForDevices(app, bob.credentials.userId, 1);
await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible();
const avatar = page.getByRole("button", { name: "Avatar" });
await avatar.click();
// once Alice has joined, Bob starts the verification
const bobVerificationRequest = await bob.evaluateHandle(
@@ -84,13 +87,17 @@ test.describe("User verification", () => {
});
test("can abort emoji verification when emoji mismatch", async ({
app,
page,
bot: bob,
user: aliceCredentials,
toasts,
room: { roomId: dmRoomId },
}) => {
await waitForDeviceKeys(page);
await waitForDevices(app, bob.credentials.userId, 1);
await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible();
const avatar = page.getByRole("button", { name: "Avatar" });
await avatar.click();
// once Alice has joined, Bob starts the verification
const bobVerificationRequest = await bob.evaluateHandle(
@@ -154,15 +161,3 @@ async function createDMRoom(client: Client, userId: string): Promise<string> {
],
});
}
/**
* Wait until we get the other user's device keys.
* In newer rust-crypto versions, the verification request will be ignored if we
* don't have the sender's device keys.
*/
async function waitForDeviceKeys(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible();
const avatar = await page.getByRole("button", { name: "Avatar" });
await avatar.click();
await expect(page.getByText("1 session")).toBeVisible();
}

View File

@@ -28,11 +28,13 @@ import { Bot } from "../../pages/bot";
* @param page - the playwright `page` fixture
* @param homeserver - the homeserver to use
* @param credentials - the credentials to use for the bot client
* @param usePassphrase - whether to use a passphrase when creating the recovery key
*/
export async function createBot(
page: Page,
homeserver: HomeserverInstance,
credentials: Credentials,
usePassphrase = false,
): Promise<{ botClient: Bot; recoveryKey: GeneratedSecretStorageKey; expectedBackupVersion: string }> {
// Visit the login page of the app, to load the matrix sdk
await page.goto("/#/login");
@@ -44,6 +46,7 @@ export async function createBot(
const botClient = new Bot(page, homeserver, {
bootstrapCrossSigning: true,
bootstrapSecretStorage: true,
usePassphrase,
});
botClient.setCredentials(credentials);
// Backup is prepared in the background. Poll until it is ready.
@@ -142,11 +145,13 @@ export async function checkDeviceIsCrossSigned(app: ElementAppPage): Promise<voi
* @param app -` ElementAppPage` wrapper for the playwright `Page`.
* @param expectedBackupVersion - the version of the backup we expect to be connected to.
* @param checkBackupPrivateKeyInCache - whether to check that the backup decryption key is cached locally
* @param checkBackupKeyIn4S - whether to check that the backup key is stored in 4S
*/
export async function checkDeviceIsConnectedKeyBackup(
app: ElementAppPage,
expectedBackupVersion: string,
checkBackupPrivateKeyInCache: boolean,
checkBackupKeyIn4S: boolean = true,
): Promise<void> {
// Sanity check the given backup version: if it's null, something went wrong earlier in the test.
if (!expectedBackupVersion) {
@@ -189,7 +194,7 @@ export async function checkDeviceIsConnectedKeyBackup(
// The active backup version is as expected
expect(activeBackupVersion).toBe(expectedBackupVersion);
// The backup key is stored in 4S
expect(backupKeyIn4S).toBe(true);
if (checkBackupKeyIn4S) expect(backupKeyIn4S).toBe(true);
if (checkBackupPrivateKeyInCache) {
// The backup key is available locally
@@ -213,13 +218,13 @@ export async function logIntoElement(page: Page, credentials: Credentials, secur
// if a securityKey was given, verify the new device
if (securityKey !== undefined) {
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key" }).click();
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key" }).click();
const useSecurityKey = page.locator(".mx_Dialog").getByRole("button", { name: "use your Security Key" });
const useSecurityKey = page.locator(".mx_Dialog").getByRole("button", { name: "use your Recovery Key" });
if (await useSecurityKey.isVisible()) {
await useSecurityKey.click();
}
// Fill in the security key
// Fill in the recovery key
await page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey);
await page.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
await page.getByRole("button", { name: "Done" }).click();
@@ -246,15 +251,15 @@ export async function logOutOfElement(page: Page, discardKeys: boolean = false)
}
/**
* Open the encryption settings, and verify the current session using the security key.
* Open the encryption settings, and verify the current session using the recovery key.
*
* @param app - `ElementAppPage` wrapper for the playwright `Page`.
* @param securityKey - The security key (i.e., 4S key), set up during a previous session.
* @param securityKey - The recovery key (i.e., 4S key), set up during a previous session.
*/
export async function verifySession(app: ElementAppPage, securityKey: string) {
const settings = await app.settings.openUserSettings("Encryption");
await settings.getByRole("button", { name: "Verify this device" }).click();
await app.page.getByRole("button", { name: "Verify with Security Key" }).click();
await app.page.getByRole("button", { name: "Verify with Recovery 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();
@@ -288,7 +293,7 @@ export async function doTwoWaySasVerification(page: Page, verifier: JSHandle<Ver
*
* Assumes that the current device has been cross-signed (which means that we skip a step where we set it up).
*
* Returns the security key
* Returns the recovery key
*/
export async function enableKeyBackup(app: ElementAppPage): Promise<string> {
await app.settings.openUserSettings("Security & Privacy");
@@ -316,9 +321,9 @@ export async function completeCreateSecretStorageDialog(
const currentDialogLocator = page.locator(".mx_Dialog");
await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible();
// "Generate a Security Key" is selected by default
// "Generate a Recovery 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 expect(currentDialogLocator.getByRole("heading", { name: "Save your Recovery 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());
@@ -342,7 +347,7 @@ export async function completeCreateSecretStorageDialog(
}
/**
* Click on copy and continue buttons to dismiss the security key dialog
* Click on copy and continue buttons to dismiss the recovery key dialog
*/
export async function copyAndContinue(page: Page) {
await page.getByRole("button", { name: "Copy" }).click();
@@ -499,3 +504,31 @@ export async function deleteCachedSecrets(page: Page) {
});
await page.reload();
}
/**
* Wait until the given user has a given number of devices.
* This function will check the device keys ten times and if
* the expected number of devices were not found by then, an
* error is thrown.
*/
export async function waitForDevices(
app: ElementAppPage,
userId: string,
expectedNumberOfDevices: number,
): Promise<void> {
const result = await app.client.evaluate(
async (cli, { userId, expectedNumberOfDevices }) => {
for (let i = 0; i < 10; ++i) {
const userDeviceMap = await cli.getCrypto()?.getUserDeviceInfo([userId], true);
const deviceMap = userDeviceMap?.get(userId);
if (deviceMap.size === expectedNumberOfDevices) return true;
await new Promise((r) => setTimeout(r, 500));
}
return false;
},
{ userId, expectedNumberOfDevices },
);
if (!result) {
throw new Error(`User ${userId} did not have ${expectedNumberOfDevices} devices within ten iterations!`);
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type Page } from "@playwright/test";
import { test, expect } from "../../../element-web-test";
test.describe("Search section of the room list", () => {
test.use({
labsFlags: ["feature_new_room_list"],
});
/**
* Get the search section of the room list
* @param page
*/
function getSearchSection(page: Page) {
return page.getByRole("search");
}
test.beforeEach(async ({ page, app, user }) => {
// The notification toast is displayed above the search section
await app.closeNotificationToast();
});
test("should render the search section", { tag: "@screenshot" }, async ({ page, app, user }) => {
const searchSection = getSearchSection(page);
// exact=false to ignore the shortcut which is related to the OS
await expect(searchSection.getByRole("button", { name: "Search", exact: false })).toBeVisible();
await expect(searchSection).toMatchScreenshot("search-section.png");
});
test("should open the spotlight when the search button is clicked", async ({ page, app, user }) => {
const searchSection = getSearchSection(page);
await searchSection.getByRole("button", { name: "Search", exact: false }).click();
// The spotlight should be displayed
await expect(page.getByRole("dialog", { name: "Search Dialog" })).toBeVisible();
});
test("should open the room directory when the search button is clicked", async ({ page, app, user }) => {
const searchSection = getSearchSection(page);
await searchSection.getByRole("button", { name: "Explore rooms" }).click();
const dialog = page.getByRole("dialog", { name: "Search Dialog" });
// The room directory should be displayed
await expect(dialog).toBeVisible();
// The public room filter should be displayed
await expect(dialog.getByText("Public rooms")).toBeVisible();
});
});

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type Page } from "@playwright/test";
import { test, expect } from "../../../element-web-test";
test.describe("Search section of the room list", () => {
test.use({
labsFlags: ["feature_new_room_list"],
});
/**
* Get the room list view
* @param page
*/
function getRoomListView(page: Page) {
return page.getByTestId("room-list-view");
}
test.beforeEach(async ({ page, app, user }) => {
// The notification toast is displayed above the search section
await app.closeNotificationToast();
});
test("should render the room list view", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomListView(page);
await expect(roomListView).toMatchScreenshot("room-list-view.png");
});
});

View File

@@ -0,0 +1,35 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("Module loading", () => {
test.use({
displayName: "Manny",
});
test.describe("Example Module", () => {
test.use({
config: {
modules: ["/modules/example-module.js"],
},
page: async ({ page }, use) => {
await page.route("/modules/example-module.js", async (route) => {
await route.fulfill({ path: "playwright/sample-files/example-module.js" });
});
await use(page);
},
});
test("should show alert", async ({ page }) => {
const dialogPromise = page.waitForEvent("dialog");
await page.goto("/");
const dialog = await dialogPromise;
expect(dialog.message()).toBe("Testing module loading successful!");
});
});
});

View File

@@ -57,8 +57,8 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => {
await util.openThread("ThreadRoot");
// Then the thread root is marked as read in the main timeline,
// 30 remaining messages are unread - 7 messages are displayed under the thread root
await util.assertUnread(room2, 30 - 7);
// 30 remaining messages are unread - 6 messages are displayed under the thread root
await util.assertUnread(room2, 30 - 6);
});
test("Creating a new thread based on a reply makes the room unread", async ({

View File

@@ -67,7 +67,7 @@ test.describe("Encryption tab", () => {
"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");
await verifySession(app, recoveryKey.encodedPrivateKey);
// We need to delete the cached secrets
await deleteCachedSecrets(page);
@@ -99,7 +99,7 @@ test.describe("Encryption tab", () => {
app,
util,
}) => {
await verifySession(app, "new passphrase");
await verifySession(app, recoveryKey.encodedPrivateKey);
// We need to delete the cached secrets
await deleteCachedSecrets(page);

View File

@@ -43,7 +43,7 @@ class Helpers {
*/
async verifyDevice(recoveryKey: GeneratedSecretStorageKey) {
// Select the security phrase
await this.page.getByRole("button", { name: "Verify with Security Key or Phrase" }).click();
await this.page.getByRole("button", { name: "Verify with Recovery Key" }).click();
await this.enterRecoveryKey(recoveryKey);
await this.page.getByRole("button", { name: "Done" }).click();
}
@@ -53,9 +53,6 @@ class Helpers {
* @param recoveryKey
*/
async enterRecoveryKey(recoveryKey: GeneratedSecretStorageKey) {
// Select to use recovery key
await this.page.getByRole("button", { name: "use your Security Key" }).click();
// Fill the recovery key
const dialog = this.page.locator(".mx_Dialog");
await dialog.getByRole("textbox").fill(recoveryKey.encodedPrivateKey);
@@ -94,7 +91,7 @@ class Helpers {
}
/**
* Get the security key from the clipboard and fill in the input field
* Get the recovery key from the clipboard and fill in the input field
* Then click on the finish button
* @param title - The title of the dialog
* @param confirmButtonLabel - The label of the confirm button

View File

@@ -7,22 +7,25 @@
import { test, expect } from ".";
import { checkDeviceIsConnectedKeyBackup, createBot, verifySession } from "../../crypto/utils";
import type { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
test.describe("Recovery section in Encryption tab", () => {
test.use({
displayName: "Alice",
});
let recoveryKey: GeneratedSecretStorageKey;
test.beforeEach(async ({ page, homeserver, credentials }) => {
// The bot bootstraps cross-signing, creates a key backup and sets up a recovery key
await createBot(page, homeserver, credentials);
const res = await createBot(page, homeserver, credentials);
recoveryKey = res.recoveryKey;
});
test(
"should change the recovery key",
{ tag: ["@screenshot", "@no-webkit"] },
async ({ page, app, homeserver, credentials, util, context }) => {
await verifySession(app, "new passphrase");
await verifySession(app, recoveryKey.encodedPrivateKey);
const dialog = await util.openEncryptionTab();
// The user can only change the recovery key
@@ -49,7 +52,7 @@ test.describe("Recovery section in Encryption tab", () => {
);
test("should setup the recovery key", { tag: ["@screenshot", "@no-webkit"] }, async ({ page, app, util }) => {
await verifySession(app, "new passphrase");
await verifySession(app, recoveryKey.encodedPrivateKey);
await util.removeSecretStorageDefaultKeyId();
// The key backup is deleted and the user needs to set it up

View File

@@ -25,13 +25,9 @@ test.describe("Security user settings tab", () => {
},
});
test.beforeEach(async ({ page, user }) => {
test.beforeEach(async ({ page, app, user }) => {
// Dismiss "Notification" toast
await page
.locator(".mx_Toast_toast", { hasText: "Notifications" })
.getByRole("button", { name: "Dismiss" })
.click();
await app.closeNotificationToast();
await page.locator(".mx_Toast_buttons").getByRole("button", { name: "Yes" }).click(); // Allow analytics
});

View File

@@ -19,7 +19,6 @@ test.describe("UserView", () => {
const rightPanel = page.locator("#mx_RightPanel");
await expect(rightPanel.getByRole("heading", { name: bot.credentials.displayName, exact: true })).toBeVisible();
await expect(rightPanel.getByText("1 session")).toBeVisible();
await expect(rightPanel).toMatchScreenshot("user-info.png", {
mask: [page.locator(".mx_UserInfo_profile_mxid")],
css: `

View File

@@ -202,4 +202,15 @@ export class ElementAppPage {
}
return this.page.locator(`id=${labelledById ?? describedById}`);
}
/**
* Close the notification toast
*/
public closeNotificationToast(): Promise<void> {
// Dismiss "Notification" toast
return this.page
.locator(".mx_Toast_toast", { hasText: "Notifications" })
.getByRole("button", { name: "Dismiss" })
.click();
}
}

View File

@@ -41,6 +41,10 @@ export interface CreateBotOpts {
* Whether to bootstrap the secret storage
*/
bootstrapSecretStorage?: boolean;
/**
* Whether to use a passphrase when creating the recovery key
*/
usePassphrase?: boolean;
}
const defaultCreateBotOptions = {
@@ -48,6 +52,7 @@ const defaultCreateBotOptions = {
autoAcceptInvites: true,
startClient: true,
bootstrapCrossSigning: true,
usePassphrase: false,
} satisfies CreateBotOpts;
type ExtendedMatrixClient = MatrixClient & { __playwright_recovery_key: GeneratedSecretStorageKey };
@@ -206,8 +211,8 @@ export class Bot extends Client {
}
if (this.opts.bootstrapSecretStorage) {
await clientHandle.evaluate(async (cli) => {
const passphrase = "new passphrase";
await clientHandle.evaluate(async (cli, usePassphrase) => {
const passphrase = usePassphrase ? "new passphrase" : undefined;
const recoveryKey = await cli.getCrypto().createRecoveryKeyFromPassphrase(passphrase);
Object.assign(cli, { __playwright_recovery_key: recoveryKey });
@@ -216,7 +221,7 @@ export class Bot extends Client {
setupNewKeyBackup: true,
createSecretStorageKey: () => Promise.resolve(recoveryKey),
});
});
}, this.opts.usePassphrase);
}
return clientHandle;

View File

@@ -0,0 +1,16 @@
/*
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.
*/
export default class ExampleModule {
static moduleApiVersion = "^0.1.0";
constructor(api) {
this.api = api;
}
async load() {
alert("Testing module loading successful!");
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -25,7 +25,7 @@ import { type HomeserverContainer, type StartedHomeserverContainer } from "./Hom
import { type StartedMatrixAuthenticationServiceContainer } from "./mas.ts";
import { Api, ClientServerApi, type Verb } from "../plugins/utils/api.ts";
const TAG = "develop@sha256:73803f5af0ff70926d54cccabdb4a6e3abf6a7f96297955e72a2652ceb5c00c3";
const TAG = "develop@sha256:dfacd4d40994c77eb478fc5773913a38fbf07d593421a5410c5dafb8330ddd13";
const DEFAULT_CONFIG = {
server_name: "localhost",

View File

@@ -269,6 +269,8 @@
@import "./views/right_panel/_VerificationPanel.pcss";
@import "./views/right_panel/_WidgetCard.pcss";
@import "./views/room_settings/_AliasSettings.pcss";
@import "./views/rooms/RoomListView/_RoomListSearch.pcss";
@import "./views/rooms/RoomListView/_RoomListView.pcss";
@import "./views/rooms/_AppsDrawer.pcss";
@import "./views/rooms/_Autocomplete.pcss";
@import "./views/rooms/_AuxPanel.pcss";

View File

@@ -104,6 +104,12 @@ Please see LICENSE files in the repository root for full details.
}
}
.mx_QuickSettingsButton_ContextMenuWrapper_new_room_list {
.mx_QuickThemeSwitcher {
margin-top: var(--cpd-space-2x);
}
}
.mx_QuickSettingsButton_icon {
// TODO remove when all icons have fill=currentColor
* {

View File

@@ -35,6 +35,7 @@ Please see LICENSE files in the repository root for full details.
width: 100%;
flex: 0 0 auto;
margin-right: 2px;
padding-bottom: 1em;
}
}

View File

@@ -37,10 +37,6 @@ Please see LICENSE files in the repository root for full details.
padding: var(--cpd-space-2x) 0 var(--cpd-space-4x);
margin: 0 var(--cpd-space-4x);
.mx_UserInfo_container_verifyButton {
margin-top: $spacing-8;
}
& + .mx_UserInfo_container {
border-top: 1px solid $separator;
}
@@ -180,6 +176,28 @@ Please see LICENSE files in the repository root for full details.
opacity: 1;
}
.mx_UserInfo_verification {
margin-top: var(--cpd-space-4x);
height: 36px;
.mx_UserInfo_verified_badge {
min-width: 68px;
height: 20px;
.mx_UserInfo_verified_icon {
flex-shrink: 0;
}
.mx_UserInfo_verified_label {
margin: 0;
}
}
.mx_UserInfo_verification_unavailable {
color: var(--cpd-color-text-secondary);
}
}
.mx_UserInfo_memberDetails {
.mx_UserInfo_profileField {
display: flex;
@@ -226,45 +244,6 @@ Please see LICENSE files in the repository root for full details.
flex: 1 1 0;
}
.mx_UserInfo_devices {
.mx_UserInfo_device {
display: flex;
margin: $spacing-8 0;
&.mx_UserInfo_device_verified {
.mx_UserInfo_device_trusted {
color: $accent;
}
}
&.mx_UserInfo_device_unverified {
.mx_UserInfo_device_trusted {
color: $alert;
}
}
.mx_UserInfo_device_name {
flex: 1;
margin: 0 5px;
word-break: break-word;
}
}
/* both for icon in expand button and device item */
.mx_E2EIcon {
/* don't squeeze */
flex: 0 0 auto;
margin: 0;
width: 12px;
height: 12px;
}
.mx_UserInfo_expand {
column-gap: 5px; /* cf: mx_UserInfo_device_name */
margin-bottom: 11px;
align-items: initial; /* Cancel the default property */
}
}
&.mx_UserInfo_smallAvatar {
.mx_UserInfo_avatar {
.mx_UserInfo_avatar_transition {

View File

@@ -0,0 +1,39 @@
/*
* 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_RoomListSearch {
/* From figma, this should be aligned with the room header */
height: 64px;
box-sizing: border-box;
border-bottom: 1px solid var(--cpd-color-bg-subtle-primary);
padding: 0 var(--cpd-space-3x);
svg {
fill: var(--cpd-color-icon-secondary);
}
.mx_RoomListSearch_search {
/* The search button should take all the remaining space */
flex: 1;
font: var(--cpd-font-body-md-regular);
color: var(--cpd-color-text-secondary);
span {
flex: 1;
kbd {
font-family: inherit;
}
}
}
.mx_RoomListSearch_explore:hover {
svg {
fill: var(--cpd-color-icon-primary);
}
}
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.mx_RoomListView {
background-color: var(--cpd-color-bg-canvas-default);
height: 100%;
border-right: 1px solid var(--cpd-color-bg-subtle-primary);
}

View File

@@ -8,6 +8,6 @@ Please see LICENSE files in the repository root for full details.
.mx_Field.mx_AppearanceUserSettingsTab_checkboxControlledField {
width: 256px;
/* matches checkbox box + padding to align with checkbox label */
margin-inline-start: calc($font-16px + 10px);
/* Line up with Settings field toggle button */
margin-inline-start: 0;
}

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

View File

@@ -10,6 +10,7 @@ Please see LICENSE files in the repository root for full details.
import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
import "@types/modernizr";
import type { ModuleLoader } from "@element-hq/element-web-module-api";
import type { logger } from "matrix-js-sdk/src/logger";
import type ContentMessages from "../ContentMessages";
import { type IMatrixClientPeg } from "../MatrixClientPeg";
@@ -45,6 +46,7 @@ import { type MatrixDispatcher } from "../dispatcher/dispatcher";
import { type DeepReadonly } from "./common";
import type MatrixChat from "../components/structures/MatrixChat";
import { type InitialCryptoSetupStore } from "../stores/InitialCryptoSetupStore";
import { type ModuleApiType } from "../modules/Api.ts";
/* eslint-disable @typescript-eslint/naming-convention */
@@ -122,6 +124,8 @@ declare global {
mxRoomScrollStateStore?: RoomScrollStateStore;
mxActiveWidgetStore?: ActiveWidgetStore;
mxOnRecaptchaLoaded?: () => void;
mxModuleLoader: ModuleLoader;
mxModuleApi: ModuleApiType;
// electron-only
electron?: Electron;

View File

@@ -38,7 +38,7 @@ export function getMonthsArray(month: Intl.DateTimeFormatOptions["month"] = "sho
// XXX: Ideally we could just specify `hour12: boolean` but it has issues on Chrome in the `en` locale
// https://support.google.com/chrome/thread/29828561?hl=en
function getTwelveHourOptions(showTwelveHour: boolean): Intl.DateTimeFormatOptions {
export function getTwelveHourOptions(showTwelveHour: boolean): Intl.DateTimeFormatOptions {
return {
hourCycle: showTwelveHour ? "h12" : "h23",
};

View File

@@ -15,7 +15,7 @@ import {
type 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, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import { type 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

View File

@@ -206,6 +206,8 @@ export interface IConfigOptions {
policy_uri?: string;
contacts?: string[];
};
modules?: string[];
}
export interface ISsoRedirectOptions {

View File

@@ -520,7 +520,7 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
},
[KeyBindingAction.GoToHome]: {
default: {
ctrlOrCmdKey: true,
ctrlKey: true,
altKey: !IS_MAC,
shiftKey: IS_MAC,
key: Key.H,

View File

@@ -10,13 +10,7 @@ Please see LICENSE files in the repository root for full details.
import React, { createRef } from "react";
import FileSaver from "file-saver";
import { logger } from "matrix-js-sdk/src/logger";
import {
type AuthDict,
type CrossSigningKeys,
MatrixError,
type UIAFlow,
type UIAResponse,
} from "matrix-js-sdk/src/matrix";
import { type AuthDict, type UIAResponse } from "matrix-js-sdk/src/matrix";
import { type GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
import classNames from "classnames";
import CheckmarkIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
@@ -61,8 +55,6 @@ enum Phase {
const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc.
interface IProps {
hasCancel?: boolean;
accountPassword?: string;
forceReset?: boolean;
resetCrossSigning?: boolean;
onFinished(ok?: boolean): void;
@@ -77,11 +69,6 @@ interface IState {
downloaded: boolean;
setPassphrase: boolean;
// does the server offer a UI auth flow with just m.login.password
// for /keys/device_signing/upload?
canUploadKeysWithPasswordOnly: boolean | null;
accountPassword: string;
accountPasswordCorrect: boolean | null;
canSkip: boolean;
passPhraseKeySelected: string;
error?: boolean;
@@ -96,7 +83,6 @@ interface IState {
*/
export default class CreateSecretStorageDialog extends React.PureComponent<IProps, IState> {
public static defaultProps: Partial<IProps> = {
hasCancel: true,
forceReset: false,
resetCrossSigning: false,
};
@@ -117,16 +103,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
passPhraseKeySelected = SecureBackupSetupMethod.Passphrase;
}
const accountPassword = props.accountPassword || "";
let canUploadKeysWithPasswordOnly: boolean | null = null;
if (accountPassword) {
// If we have an account password in memory, let's simplify and
// assume it means password auth is also supported for device
// signing key upload as well. This avoids hitting the server to
// test auth flows, which may be slow under high load.
canUploadKeysWithPasswordOnly = true;
}
const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.createSecretStorageKey();
const phase = keyFromCustomisations ? Phase.Loading : Phase.ChooseKeyPassphrase;
@@ -138,23 +114,14 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
copied: false,
downloaded: false,
setPassphrase: false,
// does the server offer a UI auth flow with just m.login.password
// for /keys/device_signing/upload?
accountPasswordCorrect: null,
canSkip: !isSecureBackupRequired(cli),
canUploadKeysWithPasswordOnly,
passPhraseKeySelected,
accountPassword,
};
}
public componentDidMount(): void {
const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.createSecretStorageKey();
if (keyFromCustomisations) this.initExtension(keyFromCustomisations);
if (this.state.canUploadKeysWithPasswordOnly === null) {
this.queryKeyUploadAuth();
}
}
private initExtension(keyFromCustomisations: Uint8Array): void {
@@ -165,27 +132,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
this.bootstrapSecretStorage();
}
private async queryKeyUploadAuth(): Promise<void> {
try {
await MatrixClientPeg.safeGet().uploadDeviceSigningKeys(undefined, {} as CrossSigningKeys);
// We should never get here: the server should always require
// UI auth to upload device signing keys. If we do, we upload
// no keys which would be a no-op.
logger.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!");
} catch (error) {
if (!(error instanceof MatrixError) || !error.data || !error.data.flows) {
logger.log("uploadDeviceSigningKeys advertised no flows!");
return;
}
const canUploadKeysWithPasswordOnly = error.data.flows.some((f: UIAFlow) => {
return f.stages.length === 1 && f.stages[0] === "m.login.password";
});
this.setState({
canUploadKeysWithPasswordOnly,
});
}
}
private onKeyPassphraseChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({
passPhraseKeySelected: e.target.value,
@@ -234,44 +180,33 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
private doBootstrapUIAuth = async (
makeRequest: (authData: AuthDict) => Promise<UIAResponse<void>>,
): Promise<void> => {
if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) {
await makeRequest({
type: "m.login.password",
identifier: {
type: "m.id.user",
user: MatrixClientPeg.safeGet().getSafeUserId(),
},
password: this.state.accountPassword,
});
} else {
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 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: MatrixClientPeg.safeGet(),
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");
}
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
title: _t("encryption|bootstrap_title"),
matrixClient: MatrixClientPeg.safeGet(),
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");
}
};
@@ -811,7 +746,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
top={this.topComponent}
title={this.titleForPhase(this.state.phase)}
titleClass={titleClass}
hasCancel={this.props.hasCancel && [Phase.Passphrase].includes(this.state.phase)}
hasCancel={false}
fixedWidth={false}
>
<div>{content}</div>

View File

@@ -12,7 +12,7 @@ import classNames from "classnames";
import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import RoomList from "../views/rooms/RoomList";
import LegacyRoomList from "../views/rooms/LegacyRoomList";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler";
import { HEADER_HEIGHT } from "../views/rooms/RoomSublist";
import { Action } from "../../dispatcher/actions";
@@ -36,6 +36,8 @@ import AccessibleButton, { type ButtonEvent } from "../views/elements/Accessible
import PosthogTrackers from "../../PosthogTrackers";
import type PageType from "../../PageTypes";
import { Landmark, LandmarkNavigation } from "../../accessibility/LandmarkNavigation";
import SettingsStore from "../../settings/SettingsStore";
import { RoomListView } from "../views/rooms/RoomListView";
interface IProps {
isMinimized: boolean;
@@ -56,7 +58,7 @@ interface IState {
export default class LeftPanel extends React.Component<IProps, IState> {
private listContainerRef = createRef<HTMLDivElement>();
private roomListRef = createRef<RoomList>();
private roomListRef = createRef<LegacyRoomList>();
private focusedElement: Element | null = null;
private isDoingStickyHeaders = false;
@@ -377,8 +379,25 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
public render(): React.ReactNode {
const containerClasses = classNames({
mx_LeftPanel: true,
mx_LeftPanel_minimized: this.props.isMinimized,
});
const roomListClasses = classNames("mx_LeftPanel_actualRoomListContainer", "mx_AutoHideScrollbar");
const useNewRoomList = SettingsStore.getValue("feature_new_room_list");
if (useNewRoomList) {
return (
<div className={containerClasses}>
<div className="mx_LeftPanel_roomListContainer">
<RoomListView activeSpace={this.state.activeSpace} />
</div>
</div>
);
}
const roomList = (
<RoomList
<LegacyRoomList
onKeyDown={this.onKeyDown}
resizeNotifier={this.props.resizeNotifier}
onFocus={this.onFocus}
@@ -391,13 +410,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
/>
);
const containerClasses = classNames({
mx_LeftPanel: true,
mx_LeftPanel_minimized: this.props.isMinimized,
});
const roomListClasses = classNames("mx_LeftPanel_actualRoomListContainer", "mx_AutoHideScrollbar");
return (
<div className={containerClasses}>
<div className="mx_LeftPanel_roomListContainer">

View File

@@ -501,9 +501,7 @@ class LoggedInView extends React.Component<IProps, IState> {
handled = true;
break;
case KeyBindingAction.FilterRooms:
dis.dispatch({
action: "focus_room_filter",
});
dis.fire(Action.OpenSpotlight);
handled = true;
break;
case KeyBindingAction.ToggleUserMenu:

View File

@@ -11,7 +11,6 @@ import * as React from "react";
import { ALTERNATE_KEY_NAME } from "../../accessibility/KeyboardShortcuts";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { type ActionPayload } from "../../dispatcher/payloads";
import { IS_MAC, Key } from "../../Keyboard";
import { _t } from "../../languageHandler";
import AccessibleButton from "../views/elements/AccessibleButton";
@@ -22,26 +21,10 @@ interface IProps {
}
export default class RoomSearch extends React.PureComponent<IProps> {
private dispatcherRef?: string;
public componentDidMount(): void {
this.dispatcherRef = defaultDispatcher.register(this.onAction);
}
public componentWillUnmount(): void {
defaultDispatcher.unregister(this.dispatcherRef);
}
private openSpotlight(): void {
defaultDispatcher.fire(Action.OpenSpotlight);
}
private onAction = (payload: ActionPayload): void => {
if (payload.action === "focus_room_filter") {
this.openSpotlight();
}
};
public render(): React.ReactNode {
const classes = classNames(
{

View File

@@ -1151,13 +1151,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
break;
case "MatrixActions.sync":
if (!this.state.matrixClientIsReady) {
const isReadyNow = Boolean(this.context.client?.isInitialSyncComplete());
this.setState(
{
matrixClientIsReady: !!this.context.client?.isInitialSyncComplete(),
matrixClientIsReady: isReadyNow,
},
() => {
// send another "initial" RVS update to trigger peeking if needed
this.onRoomViewStoreUpdate(true);
if (isReadyNow) this.onRoomViewStoreUpdate(true);
},
);
}

View File

@@ -60,8 +60,6 @@ interface IProps {
// If specified, contents will appear as a tooltip on the element and
// validation feedback tooltips will be suppressed.
tooltipContent?: JSX.Element | string;
// If specified the tooltip will be shown regardless of feedback
forceTooltipVisible?: boolean;
// If specified, the tooltip with be aligned accorindly with the field, defaults to Right.
tooltipAlignment?: ComponentProps<typeof Tooltip>["placement"];
// If specified alongside tooltipContent, the class name to apply to the
@@ -274,7 +272,6 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
validateOnChange,
validateOnFocus,
usePlaceholderAsHint,
forceTooltipVisible,
tooltipAlignment,
...inputProps
} = this.props;
@@ -283,8 +280,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
const tooltipProps: Pick<React.ComponentProps<typeof Tooltip>, "aria-live" | "aria-atomic"> = {};
let tooltipOpen = false;
if (tooltipContent || this.state.feedback) {
tooltipOpen = (this.state.focused && forceTooltipVisible) || this.state.feedbackVisible;
tooltipOpen = this.state.feedbackVisible;
if (!tooltipContent) {
tooltipProps["aria-atomic"] = "true";
tooltipProps["aria-live"] = this.state.valid ? "polite" : "assertive";

View File

@@ -25,7 +25,8 @@ import {
import { KnownMembership } from "matrix-js-sdk/src/types";
import { type UserVerificationStatus, type VerificationRequest, CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger";
import { Heading, MenuItem, Text, Tooltip } from "@vector-im/compound-web";
import { Badge, Button, Heading, InlineSpinner, MenuItem, Text, Tooltip } from "@vector-im/compound-web";
import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified";
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
import ShareIcon from "@vector-im/compound-design-tokens/assets/web/icons/share";
@@ -42,21 +43,19 @@ import dis from "../../../dispatcher/dispatcher";
import Modal from "../../../Modal";
import { _t, UserFriendlyError } from "../../../languageHandler";
import DMRoomMap from "../../../utils/DMRoomMap";
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
import { type ButtonEvent } from "../elements/AccessibleButton";
import SdkConfig from "../../../SdkConfig";
import MultiInviter from "../../../utils/MultiInviter";
import E2EIcon from "../rooms/E2EIcon";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import { textualPowerLevel } from "../../../Roles";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
import EncryptionPanel from "./EncryptionPanel";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import { verifyDevice, verifyUser } from "../../../verification";
import { verifyUser } from "../../../verification";
import { Action } from "../../../dispatcher/actions";
import { useIsEncrypted } from "../../../hooks/useIsEncrypted";
import BaseCard from "./BaseCard";
import { E2EStatus } from "../../../utils/ShieldUtils";
import ImageView from "../elements/ImageView";
import Spinner from "../elements/Spinner";
import PowerSelector from "../elements/PowerSelector";
@@ -81,7 +80,6 @@ import PosthogTrackers from "../../../PosthogTrackers";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { DirectoryMember, startDmOnFirstMessage } from "../../../utils/direct-messages";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { asyncSome } from "../../../utils/arrays";
import { Flex } from "../../utils/Flex";
import CopyableText from "../elements/CopyableText";
import { useUserTimezone } from "../../../hooks/useUserTimezone";
@@ -107,32 +105,6 @@ export const disambiguateDevices = (devices: IDevice[]): void => {
}
};
export const getE2EStatus = async (
cli: MatrixClient,
userId: string,
devices: IDevice[],
): Promise<E2EStatus | undefined> => {
const crypto = cli.getCrypto();
if (!crypto) return undefined;
const isMe = userId === cli.getUserId();
const userTrust = await crypto.getUserVerificationStatus(userId);
if (!userTrust.isCrossSigningVerified()) {
return userTrust.wasCrossSigningVerified() ? E2EStatus.Warning : E2EStatus.Normal;
}
const anyDeviceUnverified = await asyncSome(devices, async (device) => {
const { deviceId } = device;
// For your own devices, we use the stricter check of cross-signing
// verification to encourage everyone to trust their own devices via
// cross-signing so that other users can then safely trust you.
// For other people's devices, the more general verified check that
// includes locally verified devices can be used.
const deviceTrust = await crypto.getDeviceVerificationStatus(userId, deviceId);
return isMe ? !deviceTrust?.crossSigningVerified : !deviceTrust?.isVerified();
});
return anyDeviceUnverified ? E2EStatus.Warning : E2EStatus.Verified;
};
/**
* Converts the member to a DirectoryMember and starts a DM with them.
*/
@@ -146,251 +118,6 @@ async function openDmForUser(matrixClient: MatrixClient, user: Member): Promise<
await startDmOnFirstMessage(matrixClient, [startDmUser]);
}
type SetUpdating = (updating: boolean) => void;
function useHasCrossSigningKeys(
cli: MatrixClient,
member: User,
canVerify: boolean,
setUpdating: SetUpdating,
): boolean | undefined {
return useAsyncMemo(async () => {
if (!canVerify) {
return undefined;
}
setUpdating(true);
try {
return await cli.getCrypto()?.userHasCrossSigningKeys(member.userId, true);
} finally {
setUpdating(false);
}
}, [cli, member, canVerify]);
}
/**
* Display one device and the related actions
* @param userId current user id
* @param device device to display
* @param isUserVerified false when the user is not verified
* @constructor
*/
export function DeviceItem({
userId,
device,
isUserVerified,
}: {
userId: string;
device: IDevice;
isUserVerified: boolean;
}): JSX.Element {
const cli = useContext(MatrixClientContext);
const isMe = userId === cli.getUserId();
/** is the device verified? */
const isVerified = useAsyncMemo(async () => {
const deviceTrust = await cli.getCrypto()?.getDeviceVerificationStatus(userId, device.deviceId);
if (!deviceTrust) return false;
// For your own devices, we use the stricter check of cross-signing
// verification to encourage everyone to trust their own devices via
// cross-signing so that other users can then safely trust you.
// For other people's devices, the more general verified check that
// includes locally verified devices can be used.
return isMe ? deviceTrust.crossSigningVerified : deviceTrust.isVerified();
}, [cli, userId, device]);
const classes = classNames("mx_UserInfo_device", {
mx_UserInfo_device_verified: isVerified,
mx_UserInfo_device_unverified: !isVerified,
});
const iconClasses = classNames("mx_E2EIcon", {
mx_E2EIcon_normal: !isUserVerified,
mx_E2EIcon_verified: isVerified,
mx_E2EIcon_warning: isUserVerified && !isVerified,
});
const onDeviceClick = (): void => {
const user = cli.getUser(userId);
if (user) {
verifyDevice(cli, user, device);
}
};
let deviceName;
if (!device.displayName?.trim()) {
deviceName = device.deviceId;
} else {
deviceName = device.ambiguous ? device.displayName + " (" + device.deviceId + ")" : device.displayName;
}
let trustedLabel: string | undefined;
if (isUserVerified) trustedLabel = isVerified ? _t("common|trusted") : _t("common|not_trusted");
if (isVerified === undefined) {
// we're still deciding if the device is verified
return <div className={classes} title={device.deviceId} />;
} else if (isVerified) {
return (
<div className={classes} title={device.deviceId}>
<div className={iconClasses} />
<div className="mx_UserInfo_device_name">{deviceName}</div>
<div className="mx_UserInfo_device_trusted">{trustedLabel}</div>
</div>
);
} else {
return (
<AccessibleButton
className={classes}
title={device.deviceId}
aria-label={deviceName}
onClick={onDeviceClick}
>
<div className={iconClasses} />
<div className="mx_UserInfo_device_name">{deviceName}</div>
<div className="mx_UserInfo_device_trusted">{trustedLabel}</div>
</AccessibleButton>
);
}
}
/**
* Display a list of devices
* @param devices devices to display
* @param userId current user id
* @param loading displays a spinner instead of the device section
* @param isUserVerified is false when
* - the user is not verified, or
* - `MatrixClient.getCrypto.getUserVerificationStatus` async call is in progress (in which case `loading` will also be `true`)
* @constructor
*/
function DevicesSection({
devices,
userId,
loading,
isUserVerified,
}: {
devices: IDevice[];
userId: string;
loading: boolean;
isUserVerified: boolean;
}): JSX.Element {
const cli = useContext(MatrixClientContext);
const [isExpanded, setExpanded] = useState(false);
const deviceTrusts = useAsyncMemo(() => {
const cryptoApi = cli.getCrypto();
if (!cryptoApi) return Promise.resolve(undefined);
return Promise.all(devices.map((d) => cryptoApi.getDeviceVerificationStatus(userId, d.deviceId)));
}, [cli, userId, devices]);
if (loading || deviceTrusts === undefined) {
// still loading
return <Spinner />;
}
const isMe = userId === cli.getUserId();
let expandSectionDevices: IDevice[] = [];
const unverifiedDevices: IDevice[] = [];
let expandCountCaption;
let expandHideCaption;
let expandIconClasses = "mx_E2EIcon";
const dehydratedDeviceIds: string[] = [];
for (const device of devices) {
if (device.dehydrated) {
dehydratedDeviceIds.push(device.deviceId);
}
}
// If the user has exactly one device marked as dehydrated, we consider
// that as the dehydrated device, and hide it as a normal device (but
// indicate that the user is using a dehydrated device). If the user has
// more than one, that is anomalous, and we show all the devices so that
// nothing is hidden.
const dehydratedDeviceId: string | undefined = dehydratedDeviceIds.length == 1 ? dehydratedDeviceIds[0] : undefined;
let dehydratedDeviceInExpandSection = false;
if (isUserVerified) {
for (let i = 0; i < devices.length; ++i) {
const device = devices[i];
const deviceTrust = deviceTrusts[i];
// For your own devices, we use the stricter check of cross-signing
// verification to encourage everyone to trust their own devices via
// cross-signing so that other users can then safely trust you.
// For other people's devices, the more general verified check that
// includes locally verified devices can be used.
const isVerified = deviceTrust && (isMe ? deviceTrust.crossSigningVerified : deviceTrust.isVerified());
if (isVerified) {
// don't show dehydrated device as a normal device, if it's
// verified
if (device.deviceId === dehydratedDeviceId) {
dehydratedDeviceInExpandSection = true;
} else {
expandSectionDevices.push(device);
}
} else {
unverifiedDevices.push(device);
}
}
expandCountCaption = _t("user_info|count_of_verified_sessions", { count: expandSectionDevices.length });
expandHideCaption = _t("user_info|hide_verified_sessions");
expandIconClasses += " mx_E2EIcon_verified";
} else {
if (dehydratedDeviceId) {
devices = devices.filter((device) => device.deviceId !== dehydratedDeviceId);
dehydratedDeviceInExpandSection = true;
}
expandSectionDevices = devices;
expandCountCaption = _t("user_info|count_of_sessions", { count: devices.length });
expandHideCaption = _t("user_info|hide_sessions");
expandIconClasses += " mx_E2EIcon_normal";
}
let expandButton;
if (expandSectionDevices.length) {
if (isExpanded) {
expandButton = (
<AccessibleButton kind="link" className="mx_UserInfo_expand" onClick={() => setExpanded(false)}>
<div>{expandHideCaption}</div>
</AccessibleButton>
);
} else {
expandButton = (
<AccessibleButton kind="link" className="mx_UserInfo_expand" onClick={() => setExpanded(true)}>
<div className={expandIconClasses} />
<div>{expandCountCaption}</div>
</AccessibleButton>
);
}
}
let deviceList = unverifiedDevices.map((device, i) => {
return <DeviceItem key={i} userId={userId} device={device} isUserVerified={isUserVerified} />;
});
if (isExpanded) {
const keyStart = unverifiedDevices.length;
deviceList = deviceList.concat(
expandSectionDevices.map((device, i) => {
return (
<DeviceItem key={i + keyStart} userId={userId} device={device} isUserVerified={isUserVerified} />
);
}),
);
if (dehydratedDeviceInExpandSection) {
deviceList.push(<div>{_t("user_info|dehydrated_device_enabled")}</div>);
}
}
return (
<div className="mx_UserInfo_devices">
<div>{deviceList}</div>
<div>{expandButton}</div>
</div>
);
}
const MessageButton = ({ member }: { member: Member }): JSX.Element => {
const cli = useContext(MatrixClientContext);
const [busy, setBusy] = useState(false);
@@ -1400,12 +1127,84 @@ export const useDevices = (userId: string): IDevice[] | undefined | null => {
return devices;
};
function useHasCrossSigningKeys(cli: MatrixClient, member: User, canVerify: boolean): boolean | undefined {
return useAsyncMemo(async () => {
if (!canVerify) return undefined;
return await cli.getCrypto()?.userHasCrossSigningKeys(member.userId, true);
}, [cli, member, canVerify]);
}
const VerificationSection: React.FC<{
member: User | RoomMember;
devices: IDevice[];
}> = ({ member, devices }) => {
const cli = useContext(MatrixClientContext);
let content;
const homeserverSupportsCrossSigning = useHomeserverSupportsCrossSigning(cli);
const userTrust = useAsyncMemo<UserVerificationStatus | undefined>(
async () => cli.getCrypto()?.getUserVerificationStatus(member.userId),
[member.userId],
// the user verification status is not initialized
undefined,
);
const hasUserVerificationStatus = Boolean(userTrust);
const isUserVerified = Boolean(userTrust?.isVerified());
const isMe = member.userId === cli.getUserId();
const canVerify =
hasUserVerificationStatus &&
homeserverSupportsCrossSigning &&
!isUserVerified &&
!isMe &&
devices &&
devices.length > 0;
const hasCrossSigningKeys = useHasCrossSigningKeys(cli, member as User, canVerify);
if (isUserVerified) {
content = (
<Badge kind="green" className="mx_UserInfo_verified_badge">
<VerifiedIcon className="mx_UserInfo_verified_icon" height="16px" width="16px" />
<Text size="sm" weight="medium" className="mx_UserInfo_verified_label">
{_t("common|verified")}
</Text>
</Badge>
);
} else if (hasCrossSigningKeys === undefined) {
// We are still fetching the cross-signing keys for the user, show spinner.
content = <InlineSpinner size={24} />;
} else if (canVerify && hasCrossSigningKeys) {
content = (
<div className="mx_UserInfo_container_verifyButton">
<Button
className="mx_UserInfo_verify_button"
kind="tertiary"
size="sm"
onClick={() => verifyUser(cli, member as User)}
>
{_t("user_info|verify_button")}
</Button>
</div>
);
} else {
content = (
<Text className="mx_UserInfo_verification_unavailable" size="sm">
({_t("user_info|verification_unavailable")})
</Text>
);
}
return (
<Flex justify="center" align="center" className="mx_UserInfo_verification">
{content}
</Flex>
);
};
const BasicUserInfo: React.FC<{
room: Room;
member: User | RoomMember;
devices: IDevice[];
isRoomEncrypted: boolean;
}> = ({ room, member, devices, isRoomEncrypted }) => {
}> = ({ room, member }) => {
const cli = useContext(MatrixClientContext);
const powerLevels = useRoomPowerLevels(cli, room);
@@ -1503,111 +1302,10 @@ const BasicUserInfo: React.FC<{
spinner = <Spinner />;
}
// only display the devices list if our client supports E2E
const cryptoEnabled = Boolean(cli.getCrypto());
let text;
if (!isRoomEncrypted) {
if (!cryptoEnabled) {
text = _t("encryption|unsupported");
} else if (room && !room.isSpaceRoom()) {
text = _t("user_info|room_unencrypted");
}
} else if (!room.isSpaceRoom()) {
text = _t("user_info|room_encrypted");
}
let verifyButton;
const homeserverSupportsCrossSigning = useHomeserverSupportsCrossSigning(cli);
const userTrust = useAsyncMemo<UserVerificationStatus | undefined>(
async () => cli.getCrypto()?.getUserVerificationStatus(member.userId),
[member.userId],
// the user verification status is not initialized
undefined,
);
const hasUserVerificationStatus = Boolean(userTrust);
const isUserVerified = Boolean(userTrust?.isVerified());
const isMe = member.userId === cli.getUserId();
const canVerify =
hasUserVerificationStatus &&
homeserverSupportsCrossSigning &&
!isUserVerified &&
!isMe &&
devices &&
devices.length > 0;
const setUpdating: SetUpdating = (updating) => {
setPendingUpdateCount((count) => count + (updating ? 1 : -1));
};
const hasCrossSigningKeys = useHasCrossSigningKeys(cli, member as User, canVerify, setUpdating);
// Display the spinner only when
// - the devices are not populated yet, or
// - the crypto is available and we don't have the user verification status yet
const showDeviceListSpinner = (cryptoEnabled && !hasUserVerificationStatus) || devices === undefined;
if (canVerify) {
if (hasCrossSigningKeys !== undefined) {
// Note: mx_UserInfo_verifyButton is for the end-to-end tests
verifyButton = (
<div className="mx_UserInfo_container_verifyButton">
<AccessibleButton
kind="link"
className="mx_UserInfo_field mx_UserInfo_verifyButton"
onClick={() => verifyUser(cli, member as User)}
>
{_t("action|verify")}
</AccessibleButton>
</div>
);
} else if (!showDeviceListSpinner) {
// HACK: only show a spinner if the device section spinner is not shown,
// to avoid showing a double spinner
// We should ask for a design that includes all the different loading states here
verifyButton = <Spinner />;
}
}
let editDevices;
if (member.userId == cli.getUserId()) {
editDevices = (
<div>
<AccessibleButton
kind="link"
className="mx_UserInfo_field"
onClick={() => {
dis.dispatch({
action: Action.ViewUserDeviceSettings,
});
}}
>
{_t("user_info|edit_own_devices")}
</AccessibleButton>
</div>
);
}
const securitySection = (
<Container>
<h2>{_t("common|security")}</h2>
<p>{text}</p>
{verifyButton}
{cryptoEnabled && (
<DevicesSection
loading={showDeviceListSpinner}
devices={devices}
userId={member.userId}
isUserVerified={isUserVerified}
/>
)}
{editDevices}
</Container>
);
return (
<React.Fragment>
{securitySection}
<UserOptionsSection
canInvite={roomPermissions.canInvite}
member={member as RoomMember}
@@ -1615,15 +1313,12 @@ const BasicUserInfo: React.FC<{
>
{memberDetails}
</UserOptionsSection>
{adminToolsContainer}
{!isMe && (
<Container>
<IgnoreToggleButton member={member} />
</Container>
)}
{spinner}
</React.Fragment>
);
@@ -1633,9 +1328,10 @@ export type Member = User | RoomMember;
export const UserInfoHeader: React.FC<{
member: Member;
e2eStatus?: E2EStatus;
devices: IDevice[];
roomId?: string;
}> = ({ member, e2eStatus, roomId }) => {
hideVerificationSection?: boolean;
}> = ({ member, devices, roomId, hideVerificationSection }) => {
const cli = useContext(MatrixClientContext);
const onMemberAvatarClick = useCallback(() => {
@@ -1686,7 +1382,6 @@ export const UserInfoHeader: React.FC<{
const timezoneInfo = useUserTimezone(cli, member.userId);
const e2eIcon = e2eStatus ? <E2EIcon size={18} status={e2eStatus} isUser={true} /> : null;
const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, {
roomId,
withDisplayName: true,
@@ -1715,7 +1410,6 @@ export const UserInfoHeader: React.FC<{
<Heading size="sm" weight="semibold" as="h1" dir="auto">
<Flex className="mx_UserInfo_profile_name" direction="row-reverse" align="center">
{displayName}
{e2eIcon}
</Flex>
</Heading>
{presenceLabel}
@@ -1734,6 +1428,7 @@ export const UserInfoHeader: React.FC<{
</CopyableText>
</Text>
</Flex>
{!hideVerificationSection && <VerificationSection member={member} devices={devices} />}
</Container>
</React.Fragment>
);
@@ -1757,13 +1452,6 @@ const UserInfo: React.FC<IProps> = ({ user, room, onClose, phase = RightPanelPha
const isRoomEncrypted = useIsEncrypted(cli, room);
const devices = useDevices(user.userId) ?? [];
const e2eStatus = useAsyncMemo(async () => {
if (!isRoomEncrypted || !devices) {
return undefined;
}
return await getE2EStatus(cli, user.userId, devices);
}, [cli, isRoomEncrypted, user.userId, devices]);
const classes = ["mx_UserInfo"];
let cardState: IRightPanelCardState = {};
@@ -1779,14 +1467,7 @@ const UserInfo: React.FC<IProps> = ({ user, room, onClose, phase = RightPanelPha
let content: JSX.Element | undefined;
switch (phase) {
case RightPanelPhases.MemberInfo:
content = (
<BasicUserInfo
room={room as Room}
member={member as User}
devices={devices}
isRoomEncrypted={Boolean(isRoomEncrypted)}
/>
);
content = <BasicUserInfo room={room as Room} member={member as User} />;
break;
case RightPanelPhases.EncryptionPanel:
classes.push("mx_UserInfo_smallAvatar");
@@ -1811,7 +1492,12 @@ const UserInfo: React.FC<IProps> = ({ user, room, onClose, phase = RightPanelPha
const header = (
<>
<UserInfoHeader member={member} e2eStatus={e2eStatus} roomId={room?.roomId} />
<UserInfoHeader
hideVerificationSection={phase === RightPanelPhases.EncryptionPanel}
member={member}
devices={devices}
roomId={room?.roomId}
/>
</>
);

View File

@@ -9,26 +9,26 @@ Please see LICENSE files in the repository root for full details.
import { EventType, type Room, RoomType } from "matrix-js-sdk/src/matrix";
import React, { type ComponentType, createRef, type ReactComponentElement, type SyntheticEvent } from "react";
import { type IState as IRovingTabIndexState, RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { Action } from "../../../dispatcher/actions";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { type ActionPayload } from "../../../dispatcher/payloads";
import { type ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import { _t, _td, type TranslationKey } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import PosthogTrackers from "../../../PosthogTrackers";
import SettingsStore from "../../../settings/SettingsStore";
import { useFeatureEnabled } from "../../../hooks/useSettings";
import { UIComponent } from "../../../settings/UIFeature";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { type ITagMap } from "../../../stores/room-list/algorithms/models";
import { DefaultTagID, type TagID } from "../../../stores/room-list/models";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
import { type IState as IRovingTabIndexState, RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex.tsx";
import MatrixClientContext from "../../../contexts/MatrixClientContext.tsx";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents.ts";
import { Action } from "../../../dispatcher/actions.ts";
import defaultDispatcher from "../../../dispatcher/dispatcher.ts";
import { type ActionPayload } from "../../../dispatcher/payloads.ts";
import { type ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload.ts";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload.ts";
import { useEventEmitterState } from "../../../hooks/useEventEmitter.ts";
import { _t, _td, type TranslationKey } from "../../../languageHandler.tsx";
import { MatrixClientPeg } from "../../../MatrixClientPeg.ts";
import PosthogTrackers from "../../../PosthogTrackers.ts";
import SettingsStore from "../../../settings/SettingsStore.ts";
import { useFeatureEnabled } from "../../../hooks/useSettings.ts";
import { UIComponent } from "../../../settings/UIFeature.ts";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore.ts";
import { type ITagMap } from "../../../stores/room-list/algorithms/models.ts";
import { DefaultTagID, type TagID } from "../../../stores/room-list/models.ts";
import { UPDATE_EVENT } from "../../../stores/AsyncStore.ts";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore.ts";
import {
isMetaSpace,
type ISuggestedRoom,
@@ -36,26 +36,36 @@ import {
type SpaceKey,
UPDATE_SELECTED_SPACE,
UPDATE_SUGGESTED_ROOMS,
} from "../../../stores/spaces";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { arrayFastClone, arrayHasDiff } from "../../../utils/arrays";
import { objectShallowClone, objectWithOnly } from "../../../utils/objects";
import type ResizeNotifier from "../../../utils/ResizeNotifier";
import { shouldShowSpaceInvite, showAddExistingRooms, showCreateNewRoom, showSpaceInvite } from "../../../utils/space";
import { ChevronFace, ContextMenuTooltipButton, type MenuProps, useContextMenu } from "../../structures/ContextMenu";
import RoomAvatar from "../avatars/RoomAvatar";
import { BetaPill } from "../beta/BetaCard";
} from "../../../stores/spaces/index.ts";
import SpaceStore from "../../../stores/spaces/SpaceStore.ts";
import { arrayFastClone, arrayHasDiff } from "../../../utils/arrays.ts";
import { objectShallowClone, objectWithOnly } from "../../../utils/objects.ts";
import type ResizeNotifier from "../../../utils/ResizeNotifier.ts";
import {
shouldShowSpaceInvite,
showAddExistingRooms,
showCreateNewRoom,
showSpaceInvite,
} from "../../../utils/space.tsx";
import {
ChevronFace,
ContextMenuTooltipButton,
type MenuProps,
useContextMenu,
} from "../../structures/ContextMenu.tsx";
import RoomAvatar from "../avatars/RoomAvatar.tsx";
import { BetaPill } from "../beta/BetaCard.tsx";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu";
import ExtraTile from "./ExtraTile";
import RoomSublist, { type IAuxButtonProps } from "./RoomSublist";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import AccessibleButton from "../elements/AccessibleButton";
import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation";
} from "../context_menus/IconizedContextMenu.tsx";
import ExtraTile from "./ExtraTile.tsx";
import RoomSublist, { type IAuxButtonProps } from "./RoomSublist.tsx";
import { SdkContextClass } from "../../../contexts/SDKContext.ts";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts.ts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager.ts";
import AccessibleButton from "../elements/AccessibleButton.tsx";
import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation.ts";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../LegacyCallHandler.tsx";
interface IProps {
@@ -420,7 +430,7 @@ const TAG_AESTHETICS: TagAestheticsMap = {
},
};
export default class RoomList extends React.PureComponent<IProps, IState> {
export default class LegacyRoomList extends React.PureComponent<IProps, IState> {
private dispatcherRef?: string;
private treeRef = createRef<HTMLDivElement>();

View File

@@ -0,0 +1,69 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { Button } from "@vector-im/compound-web";
import ExploreIcon from "@vector-im/compound-design-tokens/assets/web/icons/explore";
import SearchIcon from "@vector-im/compound-design-tokens/assets/web/icons/search";
import { IS_MAC, Key } from "../../../../Keyboard";
import { _t } from "../../../../languageHandler";
import { ALTERNATE_KEY_NAME } from "../../../../accessibility/KeyboardShortcuts";
import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../../settings/UIFeature";
import { MetaSpace } from "../../../../stores/spaces";
import { Action } from "../../../../dispatcher/actions";
import PosthogTrackers from "../../../../PosthogTrackers";
import defaultDispatcher from "../../../../dispatcher/dispatcher";
import { Flex } from "../../../utils/Flex";
type RoomListSearchProps = {
/**
* Current active space
* The explore button is only displayed in the Home meta space
*/
activeSpace: string;
};
/**
* A search component to be displayed at the top of the room list
* The `Explore` button is displayed only in the Home meta space and when UIComponent.ExploreRooms is enabled.
*/
export function RoomListSearch({ activeSpace }: RoomListSearchProps): JSX.Element {
const displayExploreButton = activeSpace === MetaSpace.Home && shouldShowComponent(UIComponent.ExploreRooms);
return (
<Flex className="mx_RoomListSearch" role="search" gap="var(--cpd-space-2x)" align="center">
<Button
className="mx_RoomListSearch_search"
kind="secondary"
size="sm"
Icon={SearchIcon}
onClick={() => defaultDispatcher.fire(Action.OpenSpotlight)}
>
<Flex as="span" justify="space-between">
{_t("action|search")}
<kbd>{IS_MAC ? "⌘ K" : _t(ALTERNATE_KEY_NAME[Key.CONTROL]) + " K"}</kbd>
</Flex>
</Button>
{displayExploreButton && (
<Button
className="mx_RoomListSearch_explore"
kind="secondary"
size="sm"
Icon={ExploreIcon}
iconOnly={true}
aria-label={_t("action|explore_rooms")}
onClick={(ev) => {
defaultDispatcher.fire(Action.ViewRoomDirectory);
PosthogTrackers.trackInteraction("WebLeftPanelExploreRoomsButton", ev);
}}
/>
)}
</Flex>
);
}

View File

@@ -0,0 +1,33 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../../settings/UIFeature";
import { RoomListSearch } from "./RoomListSearch";
type RoomListViewProps = {
/**
* Current active space
* See {@link RoomListSearch}
*/
activeSpace: string;
};
/**
* A view component for the room list.
*/
export const RoomListView: React.FC<RoomListViewProps> = ({ activeSpace }) => {
const displayRoomSearch = shouldShowComponent(UIComponent.FilterContainer);
return (
<div className="mx_RoomListView" data-testid="room-list-view">
{displayRoomSearch && <RoomListSearch activeSpace={activeSpace} />}
</div>
);
};

View File

@@ -0,0 +1,8 @@
/*
* 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.
*/
export { RoomListView } from "./RoomListView";

View File

@@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
import React, { type ReactNode } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { type IThreepid } from "matrix-js-sdk/src/matrix";
import { EditInPlace, ErrorMessage, TooltipProvider } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
@@ -22,7 +23,6 @@ import { timeout } from "../../../utils/promise";
import { type ActionPayload } from "../../../dispatcher/payloads";
import InlineSpinner from "../elements/InlineSpinner";
import AccessibleButton from "../elements/AccessibleButton";
import Field from "../elements/Field";
import QuestionDialog from "../dialogs/QuestionDialog";
import SettingsFieldset from "./SettingsFieldset";
import { SettingsSubsectionText } from "./shared/SettingsSubsection";
@@ -117,26 +117,7 @@ export default class SetIdServer extends React.Component<IProps, IState> {
private onIdentityServerChanged = (ev: React.ChangeEvent<HTMLInputElement>): void => {
const u = ev.target.value;
this.setState({ idServer: u });
};
private getTooltip = (): JSX.Element | undefined => {
if (this.state.checking) {
return (
<div>
<InlineSpinner />
{_t("identity_server|checking")}
</div>
);
} else if (this.state.error) {
return <strong className="warning">{this.state.error}</strong>;
} else {
return undefined;
}
};
private idServerChangeEnabled = (): boolean => {
return !!this.state.idServer && !this.state.busy;
this.setState({ idServer: u, error: undefined });
};
private saveIdServer = (fullUrl: string): void => {
@@ -175,7 +156,7 @@ export default class SetIdServer extends React.Component<IProps, IState> {
// Double check that the identity server even has terms of service.
const hasTerms = await doesIdentityServerHaveTerms(MatrixClientPeg.safeGet(), fullUrl);
if (!hasTerms) {
const [confirmed] = await this.showNoTermsWarning(fullUrl);
const [confirmed] = await this.showNoTermsWarning();
save = !!confirmed;
}
@@ -213,7 +194,7 @@ export default class SetIdServer extends React.Component<IProps, IState> {
});
};
private showNoTermsWarning(fullUrl: string): Promise<[ok?: boolean]> {
private showNoTermsWarning(): Promise<[ok?: boolean]> {
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("terms|identity_server_no_terms_title"),
description: (
@@ -393,28 +374,27 @@ export default class SetIdServer extends React.Component<IProps, IState> {
return (
<SettingsFieldset legend={sectionTitle} description={bodyText}>
<form className="mx_SetIdServer" onSubmit={this.checkIdServer}>
<Field
<TooltipProvider>
<EditInPlace
cancelButtonLabel={_t("action|reset")}
label={_t("identity_server|url_field_label")}
type="text"
autoComplete="off"
onChange={this.onIdentityServerChanged}
onCancel={() => this.setState((s) => ({ idServer: s.currentClientIdServer ?? "" }))}
onClearServerErrors={() => this.setState({ error: undefined })}
onSave={this.checkIdServer}
size={48}
saveButtonLabel={_t("action|change")}
savedLabel={this.state.error ? undefined : _t("identity_server|changed")}
savingLabel={_t("identity_server|checking")}
placeholder={this.state.defaultIdServer}
value={this.state.idServer}
onChange={this.onIdentityServerChanged}
tooltipContent={this.getTooltip()}
tooltipClassName="mx_SetIdServer_tooltip"
disabled={this.state.busy}
forceValidity={this.state.error ? false : undefined}
/>
<AccessibleButton
kind="primary_sm"
onClick={this.checkIdServer}
disabled={!this.idServerChangeEnabled()}
serverInvalid={!!this.state.error}
>
{_t("action|change")}
</AccessibleButton>
{this.state.error && <ErrorMessage>{this.state.error}</ErrorMessage>}
</EditInPlace>
{discoSection}
</form>
</TooltipProvider>
</SettingsFieldset>
);
}

View File

@@ -11,7 +11,6 @@ import React, { type ChangeEvent, type ReactNode } from "react";
import { type EmptyObject } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../../../languageHandler";
import SdkConfig from "../../../../../SdkConfig";
import SettingsStore from "../../../../../settings/SettingsStore";
import SettingsFlag from "../../../elements/SettingsFlag";
import Field from "../../../elements/Field";
@@ -48,7 +47,6 @@ export default class AppearanceUserSettingsTab extends React.Component<EmptyObje
private renderAdvancedSection(): ReactNode {
if (!SettingsStore.getValue(UIFeature.AdvancedSettings)) return null;
const brand = SdkConfig.get().brand;
const toggle = (
<AccessibleButton
kind="link"
@@ -62,21 +60,18 @@ export default class AppearanceUserSettingsTab extends React.Component<EmptyObje
let advanced: React.ReactNode;
if (this.state.showAdvanced) {
const tooltipContent = _t("settings|appearance|custom_font_description", { brand });
advanced = (
<>
<SettingsFlag name="useCompactLayout" level={SettingLevel.DEVICE} useCheckbox={true} />
<SettingsFlag name="useCompactLayout" level={SettingLevel.DEVICE} />
<SettingsFlag
name="useBundledEmojiFont"
level={SettingLevel.DEVICE}
useCheckbox={true}
onChange={(checked) => this.setState({ useBundledEmojiFont: checked })}
/>
<SettingsFlag
name="useSystemFont"
level={SettingLevel.DEVICE}
useCheckbox={true}
onChange={(checked) => this.setState({ useSystemFont: checked })}
/>
<Field
@@ -89,8 +84,6 @@ export default class AppearanceUserSettingsTab extends React.Component<EmptyObje
SettingsStore.setValue("systemFont", null, SettingLevel.DEVICE, value.target.value);
}}
tooltipContent={tooltipContent}
forceTooltipVisible={true}
disabled={!this.state.useSystemFont}
value={this.state.systemFont}
/>

View File

@@ -102,20 +102,12 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Encrypti
);
break;
case "reset_identity_compromised":
content = (
<ResetIdentityPanel
variant="compromised"
onCancelClick={() => setState("main")}
onFinish={() => setState("main")}
/>
);
break;
case "reset_identity_forgot":
content = (
<ResetIdentityPanel
variant="forgot"
onCancelClick={() => setState("main")}
onFinish={() => setState("main")}
variant={state === "reset_identity_compromised" ? "compromised" : "forgot"}
onCancelClick={checkEncryptionState}
onFinish={checkEncryptionState}
/>
);
break;

View File

@@ -72,6 +72,9 @@ const SidebarUserSettingsTab: React.FC = () => {
PosthogTrackers.trackInteraction("WebSettingsSidebarTabSpacesCheckbox", event, 1);
};
// "Favourites" and "People" meta spaces are not available in the new room list
const newRoomListEnabled = useSettingValue("feature_new_room_list");
return (
<SettingsTab>
<SettingsSection>
@@ -109,33 +112,43 @@ const SidebarUserSettingsTab: React.FC = () => {
</SettingsSubsectionText>
</StyledCheckbox>
<StyledCheckbox
checked={!!favouritesEnabled}
onChange={onMetaSpaceChangeFactory(MetaSpace.Favourites, "WebSettingsSidebarTabSpacesCheckbox")}
className="mx_SidebarUserSettingsTab_checkbox"
>
<SettingsSubsectionText>
<FavouriteSolidIcon />
{_t("common|favourites")}
</SettingsSubsectionText>
<SettingsSubsectionText>
{_t("settings|sidebar|metaspaces_favourites_description")}
</SettingsSubsectionText>
</StyledCheckbox>
{!newRoomListEnabled && (
<>
<StyledCheckbox
checked={!!favouritesEnabled}
onChange={onMetaSpaceChangeFactory(
MetaSpace.Favourites,
"WebSettingsSidebarTabSpacesCheckbox",
)}
className="mx_SidebarUserSettingsTab_checkbox"
>
<SettingsSubsectionText>
<FavouriteSolidIcon />
{_t("common|favourites")}
</SettingsSubsectionText>
<SettingsSubsectionText>
{_t("settings|sidebar|metaspaces_favourites_description")}
</SettingsSubsectionText>
</StyledCheckbox>
<StyledCheckbox
checked={!!peopleEnabled}
onChange={onMetaSpaceChangeFactory(MetaSpace.People, "WebSettingsSidebarTabSpacesCheckbox")}
className="mx_SidebarUserSettingsTab_checkbox"
>
<SettingsSubsectionText>
<UserProfileSolidIcon />
{_t("common|people")}
</SettingsSubsectionText>
<SettingsSubsectionText>
{_t("settings|sidebar|metaspaces_people_description")}
</SettingsSubsectionText>
</StyledCheckbox>
<StyledCheckbox
checked={!!peopleEnabled}
onChange={onMetaSpaceChangeFactory(
MetaSpace.People,
"WebSettingsSidebarTabSpacesCheckbox",
)}
className="mx_SidebarUserSettingsTab_checkbox"
>
<SettingsSubsectionText>
<UserProfileSolidIcon />
{_t("common|people")}
</SettingsSubsectionText>
<SettingsSubsectionText>
{_t("settings|sidebar|metaspaces_people_description")}
</SettingsSubsectionText>
</StyledCheckbox>
</>
)}
<StyledCheckbox
checked={!!orphansEnabled}

View File

@@ -40,13 +40,17 @@ const QuickSettingsButton: React.FC<{
const currentRoomId = SdkContextClass.instance.roomViewStore.getRoomId();
const developerModeEnabled = useSettingValue("developerMode");
// "Favourites" and "People" meta spaces are not available in the new room list
const newRoomListEnabled = useSettingValue("feature_new_room_list");
let contextMenu: JSX.Element | undefined;
if (menuDisplayed && handle.current) {
contextMenu = (
<ContextMenu
{...alwaysAboveRightOf(handle.current.getBoundingClientRect(), ChevronFace.None, 16)}
wrapperClassName="mx_QuickSettingsButton_ContextMenuWrapper"
wrapperClassName={classNames("mx_QuickSettingsButton_ContextMenuWrapper", {
mx_QuickSettingsButton_ContextMenuWrapper_new_room_list: newRoomListEnabled,
})}
onFinished={closeMenu}
managed={false}
focusLock={true}
@@ -81,41 +85,50 @@ const QuickSettingsButton: React.FC<{
</AccessibleButton>
)}
<h4 className="mx_QuickSettingsButton_pinToSidebarHeading">
<PinUprightIcon className="mx_QuickSettingsButton_icon" />
{_t("quick_settings|metaspace_section")}
</h4>
<StyledCheckbox
className="mx_QuickSettingsButton_favouritesCheckbox"
checked={!!favouritesEnabled}
onChange={onMetaSpaceChangeFactory(MetaSpace.Favourites, "WebQuickSettingsPinToSidebarCheckbox")}
>
<FavouriteSolidIcon className="mx_QuickSettingsButton_icon" />
{_t("common|favourites")}
</StyledCheckbox>
<StyledCheckbox
className="mx_QuickSettingsButton_peopleCheckbox"
checked={!!peopleEnabled}
onChange={onMetaSpaceChangeFactory(MetaSpace.People, "WebQuickSettingsPinToSidebarCheckbox")}
>
<UserProfileSolidIcon className="mx_QuickSettingsButton_icon" />
{_t("common|people")}
</StyledCheckbox>
<AccessibleButton
className="mx_QuickSettingsButton_moreOptionsButton"
onClick={() => {
closeMenu();
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Sidebar,
});
}}
>
<OverflowHorizontalIcon className="mx_QuickSettingsButton_icon" />
{_t("quick_settings|sidebar_settings")}
</AccessibleButton>
{!newRoomListEnabled && (
<>
<h4 className="mx_QuickSettingsButton_pinToSidebarHeading">
<PinUprightIcon className="mx_QuickSettingsButton_icon" />
{_t("quick_settings|metaspace_section")}
</h4>
<StyledCheckbox
className="mx_QuickSettingsButton_favouritesCheckbox"
checked={!!favouritesEnabled}
onChange={onMetaSpaceChangeFactory(
MetaSpace.Favourites,
"WebQuickSettingsPinToSidebarCheckbox",
)}
>
<FavouriteSolidIcon className="mx_QuickSettingsButton_icon" />
{_t("common|favourites")}
</StyledCheckbox>
<StyledCheckbox
className="mx_QuickSettingsButton_peopleCheckbox"
checked={!!peopleEnabled}
onChange={onMetaSpaceChangeFactory(
MetaSpace.People,
"WebQuickSettingsPinToSidebarCheckbox",
)}
>
<UserProfileSolidIcon className="mx_QuickSettingsButton_icon" />
{_t("common|people")}
</StyledCheckbox>
<AccessibleButton
className="mx_QuickSettingsButton_moreOptionsButton"
onClick={() => {
closeMenu();
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Sidebar,
});
}}
>
<OverflowHorizontalIcon className="mx_QuickSettingsButton_icon" />
{_t("quick_settings|sidebar_settings")}
</AccessibleButton>
</>
)}
<QuickThemeSwitcher requestClose={closeMenu} />
</ContextMenu>
);

View File

@@ -58,7 +58,7 @@ const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call, skipLobby,
await Promise.all(calls.map(async (call) => await call.disconnect()));
}, []);
return (
<div className="mx_CallView">
<div className="mx_CallView" role={role}>
<AppTile
app={call.widget}
room={room}

View File

@@ -6,19 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function getDisplayAliasForAliasSet(canonicalAlias: string | null, altAliases: string[]): string | null {
// E.g. prefer one of the aliases over another
return null;
}
// This interface summarises all available customisation points and also marks
// them all as optional. This allows customisers to only define and export the
// customisations they need while still maintaining type safety.
export interface IAliasCustomisations {
getDisplayAliasForAliasSet?: typeof getDisplayAliasForAliasSet;
}
import type { AliasCustomisations } from "@element-hq/element-web-module-api";
// A real customisation module will define and export one or more of the
// customisation points that make up `IAliasCustomisations`.
export default {} as IAliasCustomisations;
// customisation points that make up `AliasCustomisations`.
export default {} as AliasCustomisations;

View File

@@ -6,20 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type ChatExportCustomisations } from "@element-hq/element-web-module-api";
import { type ExportFormat, type ExportType } from "../utils/exportUtils/exportUtils";
export type ForceChatExportParameters = {
format?: ExportFormat;
range?: ExportType;
// must be < 10**8
// only used when range is 'LastNMessages'
// default is 100
numberOfMessages?: number;
includeAttachments?: boolean;
// maximum size of exported archive
// must be > 0 and < 8000
sizeMb?: number;
};
export type ForceChatExportParameters = ReturnType<
ChatExportCustomisations<ExportFormat, ExportType>["getForceChatExportParameters"]
>;
/**
* Force parameters in room chat export
@@ -30,15 +23,8 @@ const getForceChatExportParameters = (): ForceChatExportParameters => {
return {};
};
// This interface summarises all available customisation points and also marks
// them all as optional. This allows customisers to only define and export the
// customisations they need while still maintaining type safety.
export interface IChatExportCustomisations {
getForceChatExportParameters: typeof getForceChatExportParameters;
}
// A real customisation module will define and export one or more of the
// customisation points that make up `IChatExportCustomisations`.
export default {
getForceChatExportParameters,
} as IChatExportCustomisations;
} as ChatExportCustomisations<ExportFormat, ExportType>;

View File

@@ -12,29 +12,7 @@ Please see LICENSE files in the repository root for full details.
// Populate this class with the details of your customisations when copying it.
import { type UIComponent } from "../settings/UIFeature";
/**
* Determines whether or not the active MatrixClient user should be able to use
* the given UI component. If shown, the user might still not be able to use the
* component depending on their contextual permissions. For example, invite options
* might be shown to the user but they won't have permission to invite users to
* the current room: the button will appear disabled.
* @param {UIComponent} component The component to check visibility for.
* @returns {boolean} True (default) if the user is able to see the component, false
* otherwise.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function shouldShowComponent(component: UIComponent): boolean {
return true; // default to visible
}
// This interface summarises all available customisation points and also marks
// them all as optional. This allows customisers to only define and export the
// customisations they need while still maintaining type safety.
export interface IComponentVisibilityCustomisations {
shouldShowComponent?: typeof shouldShowComponent;
}
import { type ComponentVisibilityCustomisations as IComponentVisibilityCustomisations } from "@element-hq/element-web-module-api";
// A real customisation module will define and export one or more of the
// customisation points that make up the interface above.

View File

@@ -6,19 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function requireCanonicalAliasAccessToPublish(): boolean {
// Some environments may not care about this requirement and could return false
return true;
}
// This interface summarises all available customisation points and also marks
// them all as optional. This allows customisers to only define and export the
// customisations they need while still maintaining type safety.
export interface IDirectoryCustomisations {
requireCanonicalAliasAccessToPublish?: typeof requireCanonicalAliasAccessToPublish;
}
import type { DirectoryCustomisations } from "@element-hq/element-web-module-api";
// A real customisation module will define and export one or more of the
// customisation points that make up `IDirectoryCustomisations`.
export default {} as IDirectoryCustomisations;
export default {} as DirectoryCustomisations;

View File

@@ -6,18 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function onLoggedOutAndStorageCleared(): void {
// E.g. redirect user or call other APIs after logout
}
// This interface summarises all available customisation points and also marks
// them all as optional. This allows customisers to only define and export the
// customisations they need while still maintaining type safety.
export interface ILifecycleCustomisations {
onLoggedOutAndStorageCleared?: typeof onLoggedOutAndStorageCleared;
}
import type { LifecycleCustomisations } from "@element-hq/element-web-module-api";
// A real customisation module will define and export one or more of the
// customisation points that make up `ILifecycleCustomisations`.
export default {} as ILifecycleCustomisations;
export default {} as LifecycleCustomisations;

View File

@@ -10,6 +10,7 @@ import { type MatrixClient, parseErrorResponse, type ResizeMethod } from "matrix
import { type MediaEventContent } from "matrix-js-sdk/src/types";
import { type Optional } from "matrix-events-sdk";
import type { MediaCustomisations, Media } from "@element-hq/element-web-module-api";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { type IPreparedMedia, prepEventContentAsMedia } from "./models/IMediaEventContent";
import { UserFriendlyError } from "../languageHandler";
@@ -25,7 +26,7 @@ import { UserFriendlyError } from "../languageHandler";
* A media object is a representation of a "source media" and an optional
* "thumbnail media", derived from event contents or external sources.
*/
export class Media {
class MediaImplementation implements Media {
private client: MatrixClient;
// Per above, this constructor signature can be whatever is helpful for you.
@@ -149,22 +150,27 @@ export class Media {
}
}
export type { Media };
type BaseMedia = MediaCustomisations<Partial<MediaEventContent>, MatrixClient, IPreparedMedia>;
/**
* Creates a media object from event content.
* @param {MediaEventContent} content The event content.
* @param {MatrixClient} client? Optional client to use.
* @returns {Media} The media object.
* @param {MatrixClient} client Optional client to use.
* @returns {MediaImplementation} The media object.
*/
export function mediaFromContent(content: Partial<MediaEventContent>, client?: MatrixClient): Media {
return new Media(prepEventContentAsMedia(content), client);
}
export const mediaFromContent: BaseMedia["mediaFromContent"] = (
content: Partial<MediaEventContent>,
client?: MatrixClient,
): Media => new MediaImplementation(prepEventContentAsMedia(content), client);
/**
* Creates a media object from an MXC URI.
* @param {string} mxc The MXC URI.
* @param {MatrixClient} client? Optional client to use.
* @returns {Media} The media object.
* @param {MatrixClient} client Optional client to use.
* @returns {MediaImplementation} The media object.
*/
export function mediaFromMxc(mxc?: string, client?: MatrixClient): Media {
export const mediaFromMxc: BaseMedia["mediaFromMxc"] = (mxc?: string, client?: MatrixClient): Media => {
return mediaFromContent({ url: mxc }, client);
}
};

View File

@@ -8,31 +8,10 @@
import { type Room } from "matrix-js-sdk/src/matrix";
import type { RoomListCustomisations as IRoomListCustomisations } from "@element-hq/element-web-module-api";
// Populate this file with the details of your customisations when copying it.
/**
* Determines if a room is visible in the room list or not. By default,
* all rooms are visible. Where special handling is performed by Element,
* those rooms will not be able to override their visibility in the room
* list - Element will make the decision without calling this function.
*
* This function should be as fast as possible to avoid slowing down the
* client.
* @param {Room} room The room to check the visibility of.
* @returns {boolean} True if the room should be visible, false otherwise.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function isRoomVisible(room: Room): boolean {
return true;
}
// This interface summarises all available customisation points and also marks
// them all as optional. This allows customisers to only define and export the
// customisations they need while still maintaining type safety.
export interface IRoomListCustomisations {
isRoomVisible?: typeof isRoomVisible;
}
// A real customisation module will define and export one or more of the
// customisation points that make up the interface above.
export const RoomListCustomisations: IRoomListCustomisations = {};
export const RoomListCustomisations: IRoomListCustomisations<Room> = {};

View File

@@ -6,6 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import type { UserIdentifierCustomisations } from "@element-hq/element-web-module-api";
/**
* Customise display of the user identifier
* hide userId for guests, display 3pid
@@ -19,15 +21,8 @@ function getDisplayUserIdentifier(
return userId;
}
// This interface summarises all available customisation points and also marks
// them all as optional. This allows customisers to only define and export the
// customisations they need while still maintaining type safety.
export interface IUserIdentifierCustomisations {
getDisplayUserIdentifier: typeof getDisplayUserIdentifier;
}
// A real customisation module will define and export one or more of the
// customisation points that make up `IUserIdentifierCustomisations`.
export default {
getDisplayUserIdentifier,
} as IUserIdentifierCustomisations;
} as UserIdentifierCustomisations;

View File

@@ -9,33 +9,8 @@
// Populate this class with the details of your customisations when copying it.
import { type Capability, type Widget } from "matrix-widget-api";
/**
* Approves the widget for capabilities that it requested, if any can be
* approved. Typically this will be used to give certain widgets capabilities
* without having to prompt the user to approve them. This cannot reject
* capabilities that Element will be automatically granting, such as the
* ability for Jitsi widgets to stay on screen - those will be approved
* regardless.
* @param {Widget} widget The widget to approve capabilities for.
* @param {Set<Capability>} requestedCapabilities The capabilities the widget requested.
* @returns {Set<Capability>} Resolves to the capabilities that are approved for use
* by the widget. If none are approved, this should return an empty Set.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function preapproveCapabilities(
widget: Widget,
requestedCapabilities: Set<Capability>,
): Promise<Set<Capability>> {
return new Set(); // no additional capabilities approved
}
// This interface summarises all available customisation points and also marks
// them all as optional. This allows customisers to only define and export the
// customisations they need while still maintaining type safety.
export interface IWidgetPermissionCustomisations {
preapproveCapabilities?: typeof preapproveCapabilities;
}
import type { WidgetPermissionsCustomisations } from "@element-hq/element-web-module-api";
// A real customisation module will define and export one or more of the
// customisation points that make up the interface above.
export const WidgetPermissionCustomisations: IWidgetPermissionCustomisations = {};
export const WidgetPermissionCustomisations: WidgetPermissionsCustomisations<Widget, Capability> = {};

View File

@@ -7,41 +7,8 @@
*/
// Populate this class with the details of your customisations when copying it.
import { type ITemplateParams } from "matrix-widget-api";
/**
* Provides a partial set of the variables needed to render any widget. If
* variables are missing or not provided then they will be filled with the
* application-determined defaults.
*
* This will not be called until after isReady() resolves.
* @returns {Partial<Omit<ITemplateParams, "widgetRoomId">>} The variables.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function provideVariables(): Partial<Omit<ITemplateParams, "widgetRoomId">> {
return {};
}
/**
* Resolves to whether or not the customisation point is ready for variables
* to be provided. This will block widgets being rendered.
* @returns {Promise<boolean>} Resolves when ready.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function isReady(): Promise<void> {
return; // default no waiting
}
// This interface summarises all available customisation points and also marks
// them all as optional. This allows customisers to only define and export the
// customisations they need while still maintaining type safety.
export interface IWidgetVariablesCustomisations {
provideVariables?: typeof provideVariables;
// If not provided, the app will assume that the customisation is always ready.
isReady?: typeof isReady;
}
import { type WidgetVariablesCustomisations } from "@element-hq/element-web-module-api";
// A real customisation module will define and export one or more of the
// customisation points that make up the interface above.
export const WidgetVariableCustomisations: IWidgetVariablesCustomisations = {};
export const WidgetVariableCustomisations: WidgetVariablesCustomisations = {};

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