Compare commits

..

45 Commits

Author SHA1 Message Date
David Baker
0162c82673 Use defer util 2025-02-10 14:48:15 +00:00
David Baker
a5cec9e59e More snapshots 2025-02-10 12:20:21 +00:00
David Baker
98edffac98 Add backup enabled mock to more tests 2025-02-10 12:12:38 +00:00
David Baker
96a70f24a6 Fix... bad merge? 2025-02-10 11:28:03 +00:00
David Baker
d1aef9f09a Update snapshot 2025-02-10 11:17:01 +00:00
David Baker
c4525b97b6 Have "key storage disabled" as a separate state 2025-02-10 11:05:31 +00:00
David Baker
de183113e2 Merge remote-tracking branch 'origin/develop' into dbkr/key_storage_toggle 2025-02-10 10:54:10 +00:00
David Baker
f0d9e05f85 Amalgamate duplicated CSS files 2025-02-10 10:49:58 +00:00
David Baker
9c4625d6a1 Use fixed recovery key function 2025-02-07 12:04:36 +01:00
David Baker
0c5f2b07b4 Merge branch 'develop' into dbkr/key_storage_toggle 2025-02-07 11:02:47 +00:00
David Baker
64f84cb4f8 Update snapshot 2025-02-07 11:49:07 +01:00
David Baker
7149b3d019 Don't check key backup enabled state separately
as we don't need it for all the screens
2025-02-07 11:42:32 +01:00
David Baker
2ef05c5cb9 Remove unused import 2025-02-07 11:22:54 +01:00
David Baker
8ca4a8b6ec Merge the two hooks in EncryptionUserSettingsTab 2025-02-07 11:18:42 +01:00
David Baker
e8483e0186 Use higher level interface 2025-02-07 10:54:27 +01:00
David Baker
f586c43a26 Add screenshot 2025-02-07 10:47:30 +01:00
David Baker
cfd55a6887 Switch to TypedEventEmitter and remove unnecessary loading state 2025-02-07 10:40:37 +01:00
David Baker
7c2d9f4954 Turn comments into proper tsdoc 2025-02-07 10:37:12 +01:00
David Baker
1178d77fb8 Add tsdoc & loading alt text to spinner 2025-02-07 10:29:57 +01:00
David Baker
25f8fe2009 Add missing doc & merge loading states 2025-02-07 10:26:17 +01:00
David Baker
fc9bc0903c Copy css to its own file 2025-02-07 10:22:12 +01:00
David Baker
f818d6e600 Update screenshots 2025-02-06 16:41:02 +01:00
David Baker
16c76cb20d Show the advnced section whatever the state of key storage 2025-02-06 16:21:22 +01:00
David Baker
40f9bd9480 Add playwright test & clarify slightly confusing comment 2025-02-06 15:37:18 +01:00
David Baker
e70afdb04f Test updating 2025-02-06 15:16:03 +01:00
David Baker
4a3a37323e Actually fix type imports 2025-02-06 14:24:46 +01:00
David Baker
df4c23bec7 Fix type import 2025-02-06 14:21:30 +01:00
David Baker
4ea6a33497 Merge remote-tracking branch 'origin/develop' into dbkr/key_storage_toggle 2025-02-06 14:11:56 +01:00
David Baker
87d44a7792 Test for the view model 2025-02-06 14:02:14 +01:00
David Baker
6b238d1fdc Type import 2025-02-05 16:10:59 +01:00
David Baker
1b99071dfc Fix test 2025-02-05 16:07:36 +01:00
David Baker
a26efc58f1 Tests for key storage delete panel 2025-02-05 15:54:11 +01:00
David Baker
130458783f Fix tests 2025-02-05 15:14:10 +01:00
David Baker
5ac200492c Fix type imports 2025-02-05 14:31:28 +01:00
David Baker
aa6de76a8b Fix hardcoded Element string 2025-02-05 14:25:39 +01:00
David Baker
e408715335 Add key storage delete confirm screen 2025-02-05 14:20:29 +01:00
David Baker
b7b2ea3448 Fix bits lost in merge 2025-02-05 12:01:18 +01:00
David Baker
6a20703ebc Merge remote-tracking branch 'origin/develop' into dbkr/key_storage_toggle 2025-02-05 11:51:31 +01:00
David Baker
98114c8060 Fix playwright tests 2025-01-28 17:15:44 +00:00
David Baker
4db196e6bd Fix import order 2025-01-28 16:50:08 +00:00
David Baker
4ec09f0063 Fix toast showing condition 2025-01-28 16:35:54 +00:00
David Baker
97057de900 Fix import 2025-01-28 15:44:06 +00:00
David Baker
4eb07d4dda Fix some tests 2025-01-28 15:41:22 +00:00
David Baker
98950ded60 Keys in the acceptable order 2025-01-27 17:00:17 +00:00
David Baker
5a74298d66 Add key storage toggle to Encryption settings 2025-01-27 16:56:25 +00:00
180 changed files with 3588 additions and 26950 deletions

5
.github/CODEOWNERS vendored
View File

@@ -11,11 +11,10 @@
/src/stores/SetupEncryptionStore.ts @element-hq/element-crypto-web-reviewers
/test/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers
/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @element-hq/element-crypto-web-reviewers
/src/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
/src/src/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
/test/unit-tests/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
/src/components/views/dialogs/devtools/Crypto.tsx @element-hq/element-crypto-web-reviewers
/playwright/e2e/crypto/ @element-hq/element-crypto-web-reviewers
/playwright/e2e/settings/encryption-user-tab/ @element-hq/element-crypto-web-reviewers
/src/components/views/dialogs/devtools/Crypto.tsx @element-hq/element-crypto-web-reviewers
# Ignore translations as those will be updated by GHA for Localazy download
/src/i18n/strings

View File

@@ -1,9 +1,8 @@
name: Docker
name: Dockerhub
on:
workflow_dispatch: {}
push:
tags: [v*]
pull_request: {}
schedule:
# This job can take a while, and we have usage limits, so just publish develop only twice a day
- cron: "0 7/12 * * *"
@@ -13,12 +12,9 @@ jobs:
buildx:
name: Docker Buildx
runs-on: ubuntu-24.04
environment: ${{ github.event_name != 'pull_request' && 'dockerhub' || '' }}
environment: dockerhub
permissions:
id-token: write # needed for signing the images with GitHub OIDC Token
packages: write # needed for publishing packages to GHCR
env:
TEST_TAG: vectorim/element-web:test
steps:
- uses: actions/checkout@v4
with:
@@ -26,7 +22,6 @@ jobs:
- name: Install Cosign
uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3
if: github.event_name != 'pull_request'
- name: Set up QEMU
uses: docker/setup-qemu-action@53851d14592bedcffcf25ea515637cff71ef929a # v3
@@ -38,52 +33,16 @@ jobs:
- name: Login to Docker Hub
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3
if: github.event_name != 'pull_request'
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and load
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6
with:
context: .
load: true
tags: ${{ env.TEST_TAG }}
- name: Test the image
run: |
# Make a fake module to test the image
MODULE_PATH="modules/module_name/index.js"
mkdir -p $(dirname $MODULE_PATH)
echo 'alert("Testing");' > $MODULE_PATH
# Spin up a container of the image
CONTAINER_ID=$(docker run --rm -dp 80:80 -v $(pwd)/modules:/tmp/element-web-modules ${{ env.TEST_TAG }})
# Run some smoke tests
wget --retry-connrefused --tries=5 -q --wait=3 --spider http://localhost:80/modules/module_name/index.js
MODULE_1=$(curl http://localhost:80/config.json | jq -r .modules[0])
test "$MODULE_1" = "/${MODULE_PATH}"
# Clean up
docker stop "$CONTAINER_ID"
- name: Docker meta
id: meta
uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5
if: github.event_name != 'pull_request'
with:
images: |
vectorim/element-web
ghcr.io/element-hq/element-web
tags: |
type=ref,event=branch
type=ref,event=tag
@@ -93,7 +52,6 @@ jobs:
- name: Build and push
id: build-and-push
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6
if: github.event_name != 'pull_request'
with:
context: .
push: true
@@ -105,7 +63,6 @@ jobs:
env:
DIGEST: ${{ steps.build-and-push.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }}
if: github.event_name != 'pull_request'
run: |
images=""
for tag in ${TAGS}; do
@@ -115,7 +72,6 @@ jobs:
- name: Update repo description
uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4
if: github.event_name != 'pull_request'
continue-on-error: true
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}

View File

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

View File

@@ -1,66 +1,3 @@
Changes in [1.11.93](https://github.com/element-hq/element-web/releases/tag/v1.11.93) (2025-02-25)
==================================================================================================
## ✨ Features
* [backport] Dynamically load Element Web modules in Docker entrypoint ([#29358](https://github.com/element-hq/element-web/pull/29358)). Contributed by @t3chguy.
* ChangeRecoveryKey: error handling ([#29262](https://github.com/element-hq/element-web/pull/29262)). Contributed by @richvdh.
* Dehydration: enable dehydrated device on "Set up recovery" ([#29265](https://github.com/element-hq/element-web/pull/29265)). Contributed by @richvdh.
* Render reason for invite rejection. ([#29257](https://github.com/element-hq/element-web/pull/29257)). Contributed by @Half-Shot.
* New room list: add search section ([#29251](https://github.com/element-hq/element-web/pull/29251)). Contributed by @florianduros.
* New room list: hide favourites and people meta spaces ([#29241](https://github.com/element-hq/element-web/pull/29241)). Contributed by @florianduros.
* New Room List: Create new labs flag ([#29239](https://github.com/element-hq/element-web/pull/29239)). Contributed by @MidhunSureshR.
* Stop URl preview from covering message box ([#29215](https://github.com/element-hq/element-web/pull/29215)). Contributed by @edent.
* Rename "security key" into "recovery key" ([#29217](https://github.com/element-hq/element-web/pull/29217)). Contributed by @florianduros.
* Add new verification section to user profile ([#29200](https://github.com/element-hq/element-web/pull/29200)). Contributed by @MidhunSureshR.
* Initial support for runtime modules ([#29104](https://github.com/element-hq/element-web/pull/29104)). Contributed by @t3chguy.
* Add `Forgot recovery key?` button to encryption tab ([#29202](https://github.com/element-hq/element-web/pull/29202)). Contributed by @florianduros.
* Add KeyIcon to key storage out of sync toast ([#29201](https://github.com/element-hq/element-web/pull/29201)). Contributed by @florianduros.
* Improve rendering of empty topics in the timeline ([#29152](https://github.com/element-hq/element-web/pull/29152)). Contributed by @Half-Shot.
## 🐛 Bug Fixes
* Fix font scaling in member list ([#29285](https://github.com/element-hq/element-web/pull/29285)). Contributed by @florianduros.
* Grow member list search field when resizing the right panel ([#29267](https://github.com/element-hq/element-web/pull/29267)). Contributed by @langleyd.
* Don't reload roomview on offline connectivity check ([#29243](https://github.com/element-hq/element-web/pull/29243)). Contributed by @dbkr.
* Respect user's 12/24 hour preference consistently ([#29237](https://github.com/element-hq/element-web/pull/29237)). Contributed by @t3chguy.
* Restore the accessibility role on call views ([#29225](https://github.com/element-hq/element-web/pull/29225)). Contributed by @robintown.
* Revert `GoToHome` keyboard shortcut to `Ctrl``Shift``H` on macOS ([#28577](https://github.com/element-hq/element-web/pull/28577)). Contributed by @gy-mate.
* Encryption tab: display correct encryption panel when user cancels the reset identity flow ([#29216](https://github.com/element-hq/element-web/pull/29216)). Contributed by @florianduros.
Changes in [1.11.92](https://github.com/element-hq/element-web/releases/tag/v1.11.92) (2025-02-11)
==================================================================================================
## ✨ Features
* [Backport staging] Log when we show, and hide, encryption setup toasts ([#29238](https://github.com/element-hq/element-web/pull/29238)). Contributed by @richvdh.
* Make profile header section match the designs ([#29163](https://github.com/element-hq/element-web/pull/29163)). Contributed by @MidhunSureshR.
* Always show back button in the right panel ([#29128](https://github.com/element-hq/element-web/pull/29128)). Contributed by @MidhunSureshR.
* Schedule dehydration on reload if the dehydration key is already cached locally ([#29021](https://github.com/element-hq/element-web/pull/29021)). Contributed by @uhoreg.
* update to twemoji 15.1.0 ([#29115](https://github.com/element-hq/element-web/pull/29115)). Contributed by @ara4n.
* Update matrix-widget-api ([#29112](https://github.com/element-hq/element-web/pull/29112)). Contributed by @toger5.
* Allow navigating through the memberlist using up/down keys ([#28949](https://github.com/element-hq/element-web/pull/28949)). Contributed by @MidhunSureshR.
* Style room header icons and facepile for toggled state ([#28968](https://github.com/element-hq/element-web/pull/28968)). Contributed by @MidhunSureshR.
* Move threads header below base card header ([#28969](https://github.com/element-hq/element-web/pull/28969)). Contributed by @MidhunSureshR.
* Add `Advanced` section to the user settings encryption tab ([#28804](https://github.com/element-hq/element-web/pull/28804)). Contributed by @florianduros.
* Fix outstanding UX issues with replies/mentions/keyword notifs ([#28270](https://github.com/element-hq/element-web/pull/28270)). Contributed by @taffyko.
* Distinguish room state and timeline events when dealing with widgets ([#28681](https://github.com/element-hq/element-web/pull/28681)). Contributed by @robintown.
* Switch OIDC primarily to new `/auth_metadata` API ([#29019](https://github.com/element-hq/element-web/pull/29019)). Contributed by @t3chguy.
* More memberlist changes ([#29069](https://github.com/element-hq/element-web/pull/29069)). Contributed by @MidhunSureshR.
## 🐛 Bug Fixes
* [Backport staging] Wire up the "Forgot recovery key" button for the "Key storage out of sync" toast ([#29190](https://github.com/element-hq/element-web/pull/29190)). Contributed by @RiotRobot.
* Encryption tab: hide `Advanced` section when the key storage is out of sync ([#29129](https://github.com/element-hq/element-web/pull/29129)). Contributed by @florianduros.
* Fix share button in discovery settings being disabled incorrectly ([#29151](https://github.com/element-hq/element-web/pull/29151)). Contributed by @t3chguy.
* Ensure switching rooms does not wrongly focus timeline search ([#29153](https://github.com/element-hq/element-web/pull/29153)). Contributed by @t3chguy.
* Stop showing a dialog prompting the user to enter an old recovery key ([#29143](https://github.com/element-hq/element-web/pull/29143)). Contributed by @richvdh.
* Make themed widgets reflect the effective theme ([#28342](https://github.com/element-hq/element-web/pull/28342)). Contributed by @robintown.
* support non-VS16 emoji ligatures in TwemojiMozilla ([#29100](https://github.com/element-hq/element-web/pull/29100)). Contributed by @ara4n.
* e2e test: Verify session with the encryption tab instead of the security \& privacy tab ([#29090](https://github.com/element-hq/element-web/pull/29090)). Contributed by @florianduros.
* Work around cloudflare R2 / aws client incompatability ([#29086](https://github.com/element-hq/element-web/pull/29086)). Contributed by @dbkr.
* Fix identity server settings visibility ([#29083](https://github.com/element-hq/element-web/pull/29083)). Contributed by @dbkr.
Changes in [1.11.91](https://github.com/element-hq/element-web/releases/tag/v1.11.91) (2025-01-28)
==================================================================================================
## ✨ Features

View File

@@ -1,5 +1,3 @@
# syntax=docker.io/docker/dockerfile:1.7-labs
# Builder
FROM --platform=$BUILDPLATFORM node:22-bullseye AS builder
@@ -10,7 +8,7 @@ ARG JS_SDK_BRANCH="master"
WORKDIR /src
COPY --exclude=docker . /src
COPY . /src
RUN /src/scripts/docker-link-repos.sh
RUN yarn --network-timeout=200000 install
RUN /src/scripts/docker-package.sh
@@ -21,15 +19,11 @@ RUN cp /src/config.sample.json /src/webapp/config.json
# App
FROM nginx:alpine-slim
# Install jq and moreutils for sponge, both used by our entrypoints
RUN apk add jq moreutils
COPY --from=builder /src/webapp /app
# Override default nginx config. Templates in `/etc/nginx/templates` are passed
# through `envsubst` by the nginx docker image entry point.
COPY /docker/nginx-templates/* /etc/nginx/templates/
COPY /docker/docker-entrypoint.d/* /docker-entrypoint.d/
# Tell nginx to put its pidfile elsewhere, so it can run as non-root
RUN sed -i -e 's,/var/run/nginx.pid,/tmp/nginx.pid,' /etc/nginx/nginx.conf

View File

@@ -1,34 +0,0 @@
#!/bin/sh
# Loads modules from `/tmp/element-web-modules` into config.json's `modules` field
set -e
entrypoint_log() {
if [ -z "${NGINX_ENTRYPOINT_QUIET_LOGS:-}" ]; then
echo "$@"
fi
}
# Copy these config files as a base
mkdir /tmp/element-web-config
cp /app/config*.json /tmp/element-web-config/
# If there are modules to be loaded
if [ -d "/tmp/element-web-modules" ]; then
cd /tmp/element-web-modules
for MODULE in *
do
# If the module has a package.json, use its main field as the entrypoint
ENTRYPOINT="index.js"
if [ -f "/tmp/element-web-modules/$MODULE/package.json" ]; then
ENTRYPOINT=$(jq -r '.main' "/tmp/element-web-modules/$MODULE/package.json")
fi
entrypoint_log "Loading module $MODULE with entrypoint $ENTRYPOINT"
# Append the module to the config
jq ".modules += [\"/modules/$MODULE/$ENTRYPOINT\"]" /tmp/element-web-config/config.json | sponge /tmp/element-web-config/config.json
done
fi

View File

@@ -18,12 +18,8 @@ server {
}
# covers config.json and config.hostname.json requests as it is prefix.
location /config {
root /tmp/element-web-config;
add_header Cache-Control "no-cache";
}
location /modules {
alias /tmp/element-web-modules;
}
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;

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 recovery key)
20. `force_verification`: If true, users must verify new logins (eg. with another device / their security key)
### `desktop_builds` and `mobile_builds`

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "element-web",
"version": "1.11.93",
"version": "1.11.91",
"description": "Element: the future of secure communication",
"author": "New Vector Ltd.",
"repository": {
@@ -128,7 +128,7 @@
"maplibre-gl": "^5.0.0",
"matrix-encrypt-attachment": "^1.0.3",
"matrix-events-sdk": "0.0.1",
"matrix-js-sdk": "37.0.0",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
"matrix-widget-api": "^1.10.0",
"memoize-one": "^6.0.0",
"mime": "^4.0.4",

View File

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

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

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("Recovery Key").fill(secretStorageKey);
await page.getByPlaceholder("Security Key").fill(secretStorageKey);
await page.getByRole("button", { name: "Continue" }).click();
// Enter the password

View File

@@ -6,14 +6,21 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type Locator, type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { viewRoomSummaryByName } from "../right-panel/utils";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { completeCreateSecretStorageDialog, createBot, logIntoElement } from "./utils.ts";
import { type Client } from "../../pages/client.ts";
import { type ElementAppPage } from "../../pages/ElementAppPage.ts";
const ROOM_NAME = "Test room";
const NAME = "Alice";
function getMemberTileByName(page: Page, name: string): Locator {
return page.locator(`.mx_MemberTileView, [title="${name}"]`);
}
test.use({
displayName: NAME,
synapseConfig: {
@@ -50,40 +57,36 @@ test.describe("Dehydration", () => {
await completeCreateSecretStorageDialog(page);
await expectDehydratedDeviceEnabled(app);
// Open the settings again
await app.settings.openUserSettings("Security & Privacy");
// The Security tab should indicate that there is a dehydrated device present
await expect(securityTab.getByText("Offline device enabled")).toBeVisible();
await app.settings.closeDialog();
// the dehydrated device gets created with the name "Dehydrated
// device". We want to make sure that it is not visible as a normal
// device.
const sessionsTab = await app.settings.openUserSettings("Sessions");
await expect(sessionsTab.getByText("Dehydrated device")).not.toBeVisible();
});
test("'Set up recovery' creates dehydrated device", async ({ app, credentials, page }) => {
await logIntoElement(page, credentials);
const settingsDialogLocator = await app.settings.openUserSettings("Encryption");
await settingsDialogLocator.getByRole("button", { name: "Set up recovery" }).click();
// First it displays an informative panel about the recovery key
await expect(settingsDialogLocator.getByRole("heading", { name: "Set up recovery" })).toBeVisible();
await settingsDialogLocator.getByRole("button", { name: "Continue" }).click();
// Next, it displays the new recovery key. We click on the copy button.
await expect(settingsDialogLocator.getByText("Save your recovery key somewhere safe")).toBeVisible();
await settingsDialogLocator.getByRole("button", { name: "Copy" }).click();
const recoveryKey = await app.getClipboard();
await settingsDialogLocator.getByRole("button", { name: "Continue" }).click();
await expect(
settingsDialogLocator.getByText("Enter your recovery key to confirm", { exact: true }),
).toBeVisible();
await settingsDialogLocator.getByRole("textbox").fill(recoveryKey);
await settingsDialogLocator.getByRole("button", { name: "Finish set up" }).click();
await app.settings.closeDialog();
await expectDehydratedDeviceEnabled(app);
// now check that the user info right-panel shows the dehydrated device
// as a feature rather than as a normal device
await app.client.createRoom({ name: ROOM_NAME });
await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.locator(".mx_RightPanel").getByRole("menuitem", { name: "People" }).click();
await expect(page.locator(".mx_MemberListView")).toBeVisible();
await getMemberTileByName(page, NAME).click();
await page.locator(".mx_UserInfo_devices .mx_UserInfo_expand").click();
await expect(page.locator(".mx_UserInfo_devices").getByText("Offline device enabled")).toBeVisible();
await expect(page.locator(".mx_UserInfo_devices").getByText("Dehydrated device")).not.toBeVisible();
});
test("Reset recovery key during login re-creates dehydrated device", async ({
@@ -131,16 +134,3 @@ async function getDehydratedDeviceIds(client: Client): Promise<string[]> {
);
});
}
/** Wait for our user to have a dehydrated device */
async function expectDehydratedDeviceEnabled(app: ElementAppPage): Promise<void> {
// It might be nice to do this via the UI, but currently this info is not exposed via the UI.
//
// Note we might have to wait for the device list to be refreshed, so we wrap in `expect.poll`.
await expect
.poll(async () => {
const dehydratedDeviceIds = await getDehydratedDeviceIds(app.client);
return dehydratedDeviceIds.length;
})
.toEqual(1);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,34 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type Page } from "@playwright/test";
import { test, expect } from "../../../element-web-test";
test.describe("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

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

View File

@@ -111,4 +111,21 @@ test.describe("Encryption tab", () => {
// The user is prompted to reset their identity
await expect(dialog.getByText("Forgot your recovery key? Youll need to reset your identity.")).toBeVisible();
});
test("should warn before turning off key storage", { tag: "@screenshot" }, async ({ page, app, util }) => {
await verifySession(app, recoveryKey.encodedPrivateKey);
await util.openEncryptionTab();
await page.getByRole("checkbox", { name: "Allow key storage" }).click();
await expect(
page.getByRole("heading", { name: "Are you sure you want to turn off key storage and delete it?" }),
).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("delete-key-storage-confirm.png");
await page.getByRole("button", { name: "Delete key storage" }).click();
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).not.toBeChecked();
});
});

View File

@@ -43,7 +43,7 @@ class Helpers {
*/
async verifyDevice(recoveryKey: GeneratedSecretStorageKey) {
// Select the security phrase
await this.page.getByRole("button", { name: "Verify with Recovery Key" }).click();
await this.page.getByRole("button", { name: "Verify with Security Key" }).click();
await this.enterRecoveryKey(recoveryKey);
await this.page.getByRole("button", { name: "Done" }).click();
}
@@ -91,7 +91,7 @@ class Helpers {
}
/**
* Get the recovery key from the clipboard and fill in the input field
* Get the security key from the clipboard and fill in the input field
* Then click on the finish button
* @param title - The title of the dialog
* @param confirmButtonLabel - The label of the confirm button

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 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: 55 KiB

After

Width:  |  Height:  |  Size: 57 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: 67 KiB

After

Width:  |  Height:  |  Size: 66 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: 55 KiB

After

Width:  |  Height:  |  Size: 58 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: 144 KiB

After

Width:  |  Height:  |  Size: 145 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: 19 KiB

After

Width:  |  Height:  |  Size: 20 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:32ee365ad97dde86033e8a33e143048167271299e4c727413f3cdff48c65f8d9";
const TAG = "develop@sha256:097ebca1226a0946f83f32c2b7f14fadaaa2ff36a4313cc3900aa1db7b2162f5";
const DEFAULT_CONFIG = {
server_name: "localhost",

View File

@@ -48,6 +48,7 @@
@import "./components/views/settings/devices/_FilteredDeviceListHeader.pcss";
@import "./components/views/settings/devices/_SecurityRecommendations.pcss";
@import "./components/views/settings/devices/_SelectableDeviceTile.pcss";
@import "./components/views/settings/encryption/_KeyStoragePanel.pcss";
@import "./components/views/settings/shared/_SettingsSubsection.pcss";
@import "./components/views/settings/shared/_SettingsSubsectionHeading.pcss";
@import "./components/views/spaces/_QuickThemeSwitcher.pcss";
@@ -269,8 +270,6 @@
@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";
@@ -359,9 +358,9 @@
@import "./views/settings/_UserProfileSettings.pcss";
@import "./views/settings/encryption/_AdvancedPanel.pcss";
@import "./views/settings/encryption/_ChangeRecoveryKey.pcss";
@import "./views/settings/encryption/_DestructiveComponent.pcss";
@import "./views/settings/encryption/_EncryptionCard.pcss";
@import "./views/settings/encryption/_RecoveryPanelOutOfSync.pcss";
@import "./views/settings/encryption/_ResetIdentityPanel.pcss";
@import "./views/settings/tabs/_SettingsBanner.pcss";
@import "./views/settings/tabs/_SettingsIndent.pcss";
@import "./views/settings/tabs/_SettingsSection.pcss";

View File

@@ -0,0 +1,3 @@
.mx_KeyBackupPanel_toggleRow {
flex-direction: row;
}

View File

@@ -104,12 +104,6 @@ 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,7 +35,6 @@ 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,6 +37,10 @@ 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;
}
@@ -176,28 +180,6 @@ 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;
@@ -244,6 +226,45 @@ 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

@@ -1,39 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.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

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

View File

@@ -16,7 +16,6 @@ Please see LICENSE files in the repository root for full details.
.mx_MemberListHeaderView_invite_small {
margin-left: var(--cpd-space-3x);
margin-right: var(--cpd-space-4x);
}
.mx_MemberListHeaderView_invite_large {
@@ -34,7 +33,5 @@ Please see LICENSE files in the repository root for full details.
.mx_MemberListHeaderView_search {
width: 240px;
flex-grow: 1;
margin-left: var(--cpd-space-4x);
}
}

View File

@@ -27,11 +27,13 @@ Please see LICENSE files in the repository root for full details.
.mx_MemberTileView_name {
font: var(--cpd-font-body-md-medium);
font-size: 15px;
min-width: 0;
}
.mx_MemberTileView_userLabel {
font: var(--cpd-font-body-sm-regular);
font-size: 13px;
color: var(--cpd-color-text-secondary);
margin-left: var(--cpd-space-4x);
}

View File

@@ -69,4 +69,11 @@
flex-direction: column;
gap: var(--cpd-space-8x);
}
.mx_ChangeRecoveryKey_footer {
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
justify-content: center;
}
}

View File

@@ -5,8 +5,11 @@
* Please see LICENSE files in the repository root for full details.
*/
.mx_ResetIdentityPanel {
.mx_ResetIdentityPanel_content {
/**
* Shared by multiple components that confirm a destructive action in the user settings dialog.
*/
.mx_DestructiveComponent {
.mx_DestructiveComponent_content {
display: flex;
flex-direction: column;
gap: var(--cpd-space-3x);
@@ -16,4 +19,11 @@
text-align: center;
}
}
.mx_DestructiveComponent_footer {
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
justify-content: center;
}
}

View File

@@ -31,10 +31,3 @@
}
}
}
.mx_EncryptionCard_buttons {
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
justify-content: center;
}

View File

@@ -72,13 +72,6 @@ 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

@@ -16,6 +16,7 @@ import {
MatrixError,
HTTPError,
type IThreepid,
type UIAResponse,
} from "matrix-js-sdk/src/matrix";
import Modal from "./Modal";
@@ -180,7 +181,9 @@ export default class AddThreepid {
* with a "message" property which contains a human-readable message detailing why
* the request failed.
*/
public async checkEmailLinkClicked(): Promise<[success?: boolean, result?: IAddThreePidOnlyBody | Error | null]> {
public async checkEmailLinkClicked(): Promise<
[success?: boolean, result?: UIAResponse<IAddThreePidOnlyBody> | Error | null]
> {
try {
if (this.bind) {
const authClient = new IdentityAuthClient();
@@ -267,7 +270,7 @@ export default class AddThreepid {
*/
public async haveMsisdnToken(
msisdnToken: string,
): Promise<[success?: boolean, result?: IAddThreePidOnlyBody | Error | null]> {
): Promise<[success?: boolean, result?: UIAResponse<IAddThreePidOnlyBody> | Error | null]> {
const authClient = new IdentityAuthClient();
if (this.submitUrl) {

View File

@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type AuthDict, type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
import { type AuthDict, type MatrixClient, MatrixError, type UIAResponse } from "matrix-js-sdk/src/matrix";
import { SSOAuthEntry } from "./components/views/auth/InteractiveAuthEntryComponents";
import Modal from "./Modal";
@@ -38,7 +38,7 @@ export async function createCrossSigning(cli: MatrixClient): Promise<void> {
export async function uiAuthCallback(
matrixClient: MatrixClient,
makeRequest: (authData: AuthDict) => Promise<void>,
makeRequest: (authData: AuthDict) => Promise<UIAResponse<void>>,
): Promise<void> {
try {
await makeRequest({});

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
export function getTwelveHourOptions(showTwelveHour: boolean): Intl.DateTimeFormatOptions {
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 as baseLogger } from "matrix-js-sdk/src/logger";
import { logger } 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,8 +48,6 @@ 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')
@@ -133,7 +131,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.debug("Dismissing unverified sessions: " + Array.from(deviceIds).join(","));
logger.log("Dismissing unverified sessions: " + Array.from(deviceIds).join(","));
for (const d of deviceIds) {
this.dismissed.add(d);
}
@@ -311,21 +309,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);
// the user just hasn't set up 4S yet: prompt them to do so (unless they've explicitly said no to backups)
const disabledEvent = cli.getAccountData("m.org.matrix.custom.backup_disabled");
if (!disabledEvent || !disabledEvent.getContent()?.disabled) {
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
}
} else {
// some other condition... yikes! Show the 'set up encryption' toast: this is what we previously did
// in 'other' situations. Possibly we should consider prompting for a full reset in this case?

View File

@@ -41,7 +41,7 @@ import PlatformPeg from "./PlatformPeg";
import { formatList } from "./utils/FormattingUtils";
import SdkConfig from "./SdkConfig";
import { setDeviceIsolationMode } from "./settings/controllers/DeviceIsolationModeController.ts";
import { initialiseDehydrationIfEnabled } from "./utils/device/dehydration";
import { initialiseDehydration } from "./utils/device/dehydration";
export interface IMatrixClientCreds {
homeserverUrl: string;
@@ -347,7 +347,7 @@ class MatrixClientPegClass implements IMatrixClientPeg {
// is a new login, we will start dehydration after Secret Storage is
// unlocked.
try {
await initialiseDehydrationIfEnabled(this.matrixClient, { onlyIfKeyCached: true, rehydrate: false });
await initialiseDehydration({ onlyIfKeyCached: true, rehydrate: false }, this.matrixClient);
} catch (e) {
// We may get an error dehydrating, such as if cross-signing and
// SSSS are not set up yet. Just log the error and continue.

View File

@@ -191,10 +191,7 @@ function textForMemberEvent(
case KnownMembership.Leave:
if (ev.getSender() === ev.getStateKey()) {
if (prevContent.membership === KnownMembership.Invite) {
return () =>
reason
? _t("timeline|m.room.member|reject_invite_reason", { targetName, reason })
: _t("timeline|m.room.member|reject_invite", { targetName });
return () => _t("timeline|m.room.member|reject_invite", { targetName });
} else {
return () =>
reason

View File

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

View File

@@ -10,7 +10,13 @@ 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 } from "matrix-js-sdk/src/matrix";
import {
type AuthDict,
type CrossSigningKeys,
MatrixError,
type UIAFlow,
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";
@@ -37,7 +43,7 @@ import Spinner from "../../../../components/views/elements/Spinner";
import InteractiveAuthDialog from "../../../../components/views/dialogs/InteractiveAuthDialog";
import { type IValidationResult } from "../../../../components/views/elements/Validation";
import PassphraseConfirmField from "../../../../components/views/auth/PassphraseConfirmField";
import { initialiseDehydrationIfEnabled } from "../../../../utils/device/dehydration";
import { initialiseDehydration } from "../../../../utils/device/dehydration";
// I made a mistake while converting this and it has to be fixed!
enum Phase {
@@ -55,6 +61,8 @@ 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;
@@ -69,6 +77,11 @@ 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;
@@ -83,6 +96,7 @@ interface IState {
*/
export default class CreateSecretStorageDialog extends React.PureComponent<IProps, IState> {
public static defaultProps: Partial<IProps> = {
hasCancel: true,
forceReset: false,
resetCrossSigning: false,
};
@@ -103,6 +117,16 @@ 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;
@@ -114,14 +138,23 @@ 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 {
@@ -132,6 +165,27 @@ 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,
@@ -177,34 +231,47 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
});
};
private doBootstrapUIAuth = async (makeRequest: (authData: AuthDict) => Promise<void>): Promise<void> => {
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",
},
};
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 { 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");
}
}
};
@@ -271,7 +338,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
setupNewKeyBackup: !backupInfo,
});
}
await initialiseDehydrationIfEnabled(cli, { createNewKey: true });
await initialiseDehydration({ createNewKey: true });
this.setState({
phase: Phase.Stored,
@@ -744,7 +811,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
top={this.topComponent}
title={this.titleForPhase(this.state.phase)}
titleClass={titleClass}
hasCancel={false}
hasCancel={this.props.hasCancel && [Phase.Passphrase].includes(this.state.phase)}
fixedWidth={false}
>
<div>{content}</div>

View File

@@ -27,10 +27,13 @@ import Spinner from "../views/elements/Spinner";
export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
export type InteractiveAuthCallback<T> = {
(success: true, response: T, extra?: { emailSid?: string; clientSecret?: string }): Promise<void>;
(success: false, response: IAuthData | Error): Promise<void>;
};
type InteractiveAuthCallbackSuccess<T> = (
success: true,
response: T,
extra?: { emailSid?: string; clientSecret?: string },
) => Promise<void>;
type InteractiveAuthCallbackFailure = (success: false, response: IAuthData | Error) => Promise<void>;
export type InteractiveAuthCallback<T> = InteractiveAuthCallbackSuccess<T> & InteractiveAuthCallbackFailure;
export interface InteractiveAuthProps<T> {
// matrix client to use for UI auth requests
@@ -46,6 +49,10 @@ export interface InteractiveAuthProps<T> {
emailSid?: string;
// If true, poll to see if the auth flow has been completed out-of-band
poll?: boolean;
// If true, components will be told that the 'Continue' button
// is managed by some other party and should not be managed by
// the component itself.
continueIsManaged?: boolean;
// continueText and continueKind are passed straight through to the AuthEntryComponent.
continueText?: string;
continueKind?: ContinueKind;
@@ -281,6 +288,7 @@ export default class InteractiveAuthComponent<T> extends React.Component<Interac
stageState={this.state.stageState}
fail={this.onAuthStageFailed}
setEmailSid={this.setEmailSid}
showContinue={!this.props.continueIsManaged}
onPhaseChange={this.onPhaseChange}
requestEmailToken={this.authLogic.requestEmailToken}
continueText={this.props.continueText}

View File

@@ -12,7 +12,7 @@ import classNames from "classnames";
import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import LegacyRoomList from "../views/rooms/LegacyRoomList";
import RoomList from "../views/rooms/RoomList";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler";
import { HEADER_HEIGHT } from "../views/rooms/RoomSublist";
import { Action } from "../../dispatcher/actions";
@@ -36,8 +36,6 @@ 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;
@@ -58,7 +56,7 @@ interface IState {
export default class LeftPanel extends React.Component<IProps, IState> {
private listContainerRef = createRef<HTMLDivElement>();
private roomListRef = createRef<LegacyRoomList>();
private roomListRef = createRef<RoomList>();
private focusedElement: Element | null = null;
private isDoingStickyHeaders = false;
@@ -379,25 +377,8 @@ 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 = (
<LegacyRoomList
<RoomList
onKeyDown={this.onKeyDown}
resizeNotifier={this.props.resizeNotifier}
onFocus={this.onFocus}
@@ -410,6 +391,13 @@ 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,7 +501,9 @@ class LoggedInView extends React.Component<IProps, IState> {
handled = true;
break;
case KeyBindingAction.FilterRooms:
dis.fire(Action.OpenSpotlight);
dis.dispatch({
action: "focus_room_filter",
});
handled = true;
break;
case KeyBindingAction.ToggleUserMenu:

View File

@@ -11,6 +11,7 @@ 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";
@@ -21,10 +22,26 @@ 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,14 +1151,13 @@ 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: isReadyNow,
matrixClientIsReady: !!this.context.client?.isInitialSyncComplete(),
},
() => {
// send another "initial" RVS update to trigger peeking if needed
if (isReadyNow) this.onRoomViewStoreUpdate(true);
this.onRoomViewStoreUpdate(true);
},
);
}

View File

@@ -0,0 +1,130 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { useCallback, useEffect, useState } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
interface KeyStoragePanelState {
/**
* Whether key storage is enabled, or 'undefined' if the state is still loading.
*/
isEnabled: boolean | undefined;
/**
* A function that can be called to enable or disable key storage.
* @param enable True to turn key storage on or false to turn it off
*/
setEnabled: (enable: boolean) => void;
/**
* True if the state is still loading for the first time
*/
loading: boolean;
/**
* True if the status is in the process of being changed
*/
busy: boolean;
}
export function useKeyStoragePanelViewModel(): KeyStoragePanelState {
const [isEnabled, setIsEnabled] = useState<boolean | undefined>(undefined);
const [loading, setLoading] = useState(true);
// Whilst the change is being made, the toggle will reflect the pending value rather than the actual state
const [pendingValue, setPendingValue] = useState<boolean | undefined>(undefined);
const matrixClient = useMatrixClientContext();
const checkStatus = useCallback(async () => {
const crypto = matrixClient.getCrypto();
if (!crypto) {
logger.error("Can't check key backup status: no crypto module available");
return;
}
const info = await crypto.getKeyBackupInfo();
setIsEnabled(Boolean(info?.version));
}, [matrixClient]);
useEffect(() => {
(async () => {
await checkStatus();
setLoading(false);
})();
}, [checkStatus]);
const setEnabled = useCallback(
async (enable: boolean) => {
setPendingValue(enable);
try {
const crypto = matrixClient.getCrypto();
if (!crypto) {
logger.error("Can't change key backup status: no crypto module available");
return;
}
if (enable) {
const currentKeyBackup = await crypto.checkKeyBackupAndEnable();
if (currentKeyBackup === null) {
await crypto.resetKeyBackup();
}
// resetKeyBackup fires this off in the background without waiting, so we need to do it
// explicitly and wait for it, otherwise it won't be enabled yet when we check again.
await crypto.checkKeyBackupAndEnable();
// Set the flag so that EX no longer thinks the user wants backup disabled
await matrixClient.setAccountData("m.org.matrix.custom.backup_disabled", { disabled: false });
} else {
// Get the key backup version we're using
const info = await crypto.getKeyBackupInfo();
if (!info?.version) {
logger.error("Can't delete key backup version: no version available");
return;
}
// Bye bye backup
await crypto.deleteKeyBackupVersion(info.version);
// also turn off 4S, since this is also storing keys on the server.
// Delete the cross signing keys from secret storage
await matrixClient.deleteAccountData("m.cross_signing.master");
await matrixClient.deleteAccountData("m.cross_signing.self_signing");
await matrixClient.deleteAccountData("m.cross_signing.user_signing");
// and the key backup key (we just turned it off anyway)
await matrixClient.deleteAccountData("m.megolm_backup.v1");
// Delete the key information
const defaultKey = await matrixClient.secretStorage.getDefaultKeyId();
if (defaultKey) {
await matrixClient.deleteAccountData(`m.secret_storage.key.${defaultKey}`);
// ...and the default key pointer
await matrixClient.deleteAccountData("m.secret_storage.default_key");
}
// finally, set a flag to say that the user doesn't want key backup.
// Element X uses this to determine whether to set up automatically,
// so this will stop EX turning it back on spontaneously.
await matrixClient.setAccountData("m.org.matrix.custom.backup_disabled", { disabled: true });
}
await checkStatus();
} finally {
setPendingValue(undefined);
}
},
[setPendingValue, checkStatus, matrixClient],
);
return {
isEnabled: pendingValue ?? isEnabled,
setEnabled,
loading,
busy: pendingValue !== undefined,
};
}

View File

@@ -85,6 +85,7 @@ interface IAuthEntryProps {
requestEmailToken?: () => Promise<void>;
fail: (error: Error) => void;
clientSecret: string;
showContinue: boolean;
}
interface IPasswordAuthEntryState {
@@ -360,11 +361,9 @@ export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITerms
);
}
return (
<div className="mx_InteractiveAuthEntryComponents">
<p>{_t("auth|uia|terms")}</p>
{checkboxes}
{errorSection}
let submitButton: JSX.Element | undefined;
if (this.props.showContinue !== false) {
submitButton = (
<AccessibleButton
kind="primary"
className="mx_InteractiveAuthEntryComponents_termsSubmit"
@@ -373,6 +372,15 @@ export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITerms
>
{_t("action|accept")}
</AccessibleButton>
);
}
return (
<div className="mx_InteractiveAuthEntryComponents">
<p>{_t("auth|uia|terms")}</p>
{checkboxes}
{errorSection}
{submitButton}
</div>
);
}

View File

@@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { type MatrixClient, type UIAResponse } from "matrix-js-sdk/src/matrix";
import { type AuthType } from "matrix-js-sdk/src/interactive-auth";
import { _t } from "../../../languageHandler";
@@ -63,7 +63,7 @@ export interface InteractiveAuthDialogProps<T = unknown>
// Default is defined in _getDefaultDialogAesthetics()
aestheticsForStagePhases?: DialogAesthetics;
onFinished(success?: boolean, result?: T | Error | null): void;
onFinished(success?: boolean, result?: UIAResponse<T> | Error | null): void;
}
interface IState {
@@ -111,7 +111,7 @@ export default class InteractiveAuthDialog<T> extends React.Component<Interactiv
private onAuthFinished: InteractiveAuthCallback<T> = async (success, result): Promise<void> => {
if (success) {
this.props.onFinished(true, result as T);
this.props.onFinished(true, result);
} else {
if (result === ERROR_USER_CANCELLED) {
this.props.onFinished(false, null);

View File

@@ -25,8 +25,7 @@ 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 { 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 { Heading, MenuItem, Text, Tooltip } from "@vector-im/compound-web";
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";
@@ -43,19 +42,21 @@ import dis from "../../../dispatcher/dispatcher";
import Modal from "../../../Modal";
import { _t, UserFriendlyError } from "../../../languageHandler";
import DMRoomMap from "../../../utils/DMRoomMap";
import { type ButtonEvent } from "../elements/AccessibleButton";
import AccessibleButton, { 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 { verifyUser } from "../../../verification";
import { verifyDevice, 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";
@@ -80,6 +81,7 @@ 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";
@@ -105,6 +107,32 @@ 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.
*/
@@ -118,6 +146,251 @@ 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);
@@ -1127,84 +1400,12 @@ 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;
}> = ({ room, member }) => {
devices: IDevice[];
isRoomEncrypted: boolean;
}> = ({ room, member, devices, isRoomEncrypted }) => {
const cli = useContext(MatrixClientContext);
const powerLevels = useRoomPowerLevels(cli, room);
@@ -1302,10 +1503,111 @@ 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}
@@ -1313,12 +1615,15 @@ const BasicUserInfo: React.FC<{
>
{memberDetails}
</UserOptionsSection>
{adminToolsContainer}
{!isMe && (
<Container>
<IgnoreToggleButton member={member} />
</Container>
)}
{spinner}
</React.Fragment>
);
@@ -1328,10 +1633,9 @@ export type Member = User | RoomMember;
export const UserInfoHeader: React.FC<{
member: Member;
devices: IDevice[];
e2eStatus?: E2EStatus;
roomId?: string;
hideVerificationSection?: boolean;
}> = ({ member, devices, roomId, hideVerificationSection }) => {
}> = ({ member, e2eStatus, roomId }) => {
const cli = useContext(MatrixClientContext);
const onMemberAvatarClick = useCallback(() => {
@@ -1382,6 +1686,7 @@ 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,
@@ -1410,6 +1715,7 @@ 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}
@@ -1428,7 +1734,6 @@ export const UserInfoHeader: React.FC<{
</CopyableText>
</Text>
</Flex>
{!hideVerificationSection && <VerificationSection member={member} devices={devices} />}
</Container>
</React.Fragment>
);
@@ -1452,6 +1757,13 @@ 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 = {};
@@ -1467,7 +1779,14 @@ 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} />;
content = (
<BasicUserInfo
room={room as Room}
member={member as User}
devices={devices}
isRoomEncrypted={Boolean(isRoomEncrypted)}
/>
);
break;
case RightPanelPhases.EncryptionPanel:
classes.push("mx_UserInfo_smallAvatar");
@@ -1492,12 +1811,7 @@ const UserInfo: React.FC<IProps> = ({ user, room, onClose, phase = RightPanelPha
const header = (
<>
<UserInfoHeader
hideVerificationSection={phase === RightPanelPhases.EncryptionPanel}
member={member}
devices={devices}
roomId={room?.roomId}
/>
<UserInfoHeader member={member} e2eStatus={e2eStatus} 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.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 { 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 {
isMetaSpace,
type ISuggestedRoom,
@@ -36,36 +36,26 @@ import {
type SpaceKey,
UPDATE_SELECTED_SPACE,
UPDATE_SUGGESTED_ROOMS,
} 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";
} 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";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} 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";
} 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";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../LegacyCallHandler.tsx";
interface IProps {
@@ -430,7 +420,7 @@ const TAG_AESTHETICS: TagAestheticsMap = {
},
};
export default class LegacyRoomList extends React.PureComponent<IProps, IState> {
export default class RoomList extends React.PureComponent<IProps, IState> {
private dispatcherRef?: string;
private treeRef = createRef<HTMLDivElement>();

View File

@@ -1,69 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { 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

@@ -1,33 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React 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

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

View File

@@ -19,16 +19,14 @@ import {
} from "@vector-im/compound-web";
import CopyIcon from "@vector-im/compound-design-tokens/assets/web/icons/copy";
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key-solid";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../../languageHandler";
import { EncryptionCard } from "./EncryptionCard";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
import { copyPlaintext } from "../../../../utils/strings";
import { initialiseDehydrationIfEnabled } from "../../../../utils/device/dehydration.ts";
import { withSecretStorageKeyCache } from "../../../../SecurityManager";
import { EncryptionCardButtons } from "./EncryptionCardButtons";
import { logErrorAndShowErrorDialog } from "../../../../utils/ErrorUtils.tsx";
/**
* The possible states of the component.
@@ -124,16 +122,15 @@ export function ChangeRecoveryKey({
try {
// We need to enable the cache to avoid to prompt the user to enter the new key
// when we will try to access the secret storage during the bootstrap
await withSecretStorageKeyCache(async () => {
await crypto.bootstrapSecretStorage({
await withSecretStorageKeyCache(() =>
crypto.bootstrapSecretStorage({
setupNewSecretStorage: true,
createSecretStorageKey: async () => recoveryKey,
});
await initialiseDehydrationIfEnabled(matrixClient, { createNewKey: true });
});
}),
);
onFinish();
} catch (e) {
logErrorAndShowErrorDialog("Failed to set up secret storage", e);
logger.error("Failed to bootstrap secret storage", e);
}
}}
submitButtonLabel={
@@ -240,12 +237,12 @@ function InformationPanel({ onContinueClick, onCancelClick }: InformationPanelPr
<Text as="span" weight="medium" className="mx_InformationPanel_description">
{_t("settings|encryption|recovery|set_up_recovery_secondary_description")}
</Text>
<EncryptionCardButtons>
<div className="mx_ChangeRecoveryKey_footer">
<Button onClick={onContinueClick}>{_t("action|continue")}</Button>
<Button kind="tertiary" onClick={onCancelClick}>
{_t("action|cancel")}
</Button>
</EncryptionCardButtons>
</div>
</>
);
}
@@ -287,12 +284,12 @@ function KeyPanel({ recoveryKey, onConfirmClick, onCancelClick }: KeyPanelProps)
<CopyIcon />
</IconButton>
</div>
<EncryptionCardButtons>
<div className="mx_ChangeRecoveryKey_footer">
<Button onClick={onConfirmClick}>{_t("action|continue")}</Button>
<Button kind="tertiary" onClick={onCancelClick}>
{_t("action|cancel")}
</Button>
</EncryptionCardButtons>
</div>
</>
);
}
@@ -350,12 +347,12 @@ function KeyForm({ onCancelClick, onSubmit, recoveryKey, submitButtonLabel }: Ke
<ErrorMessage>{_t("settings|encryption|recovery|enter_key_error")}</ErrorMessage>
)}
</Field>
<EncryptionCardButtons>
<div className="mx_ChangeRecoveryKey_footer">
<Button disabled={!isKeyValid}>{submitButtonLabel}</Button>
<Button kind="tertiary" onClick={onCancelClick}>
{_t("action|cancel")}
</Button>
</EncryptionCardButtons>
</div>
</Root>
);
}

View File

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

View File

@@ -1,16 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import React, { type PropsWithChildren } from "react";
/**
* A component to present action buttons at the bottom of an {@link EncryptionCard}
* (mostly as somewhere for the common CSS to live).
*/
export function EncryptionCardButtons({ children }: PropsWithChildren): JSX.Element {
return <div className="mx_EncryptionCard_buttons">{children}</div>;
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { useCallback } from "react";
import { InlineField, InlineSpinner, Label, Root, ToggleControl } from "@vector-im/compound-web";
import type { FormEvent } from "react";
import { SettingsSection } from "../shared/SettingsSection";
import { _t } from "../../../../languageHandler";
import { SettingsHeader } from "../SettingsHeader";
import { useKeyStoragePanelViewModel } from "../../../viewmodels/settings/encryption/KeyStoragePanelViewModel";
interface Props {
/**
* Called when the user turns off the "allow key storage" toggle
*/
onKeyStorageDisableClick: () => void;
}
/**
* This component allows the user to set up or change their recovery key.
*/
export const KeyStoragePanel: React.FC<Props> = ({ onKeyStorageDisableClick }) => {
const { isEnabled, setEnabled, loading, busy } = useKeyStoragePanelViewModel();
const onKeyBackupChange = useCallback(
(e: FormEvent<HTMLInputElement>) => {
if (e.currentTarget.checked) {
setEnabled(true);
} else {
onKeyStorageDisableClick();
}
},
[setEnabled, onKeyStorageDisableClick],
);
if (loading) {
return <InlineSpinner aria-label={_t("common|loading")} />;
}
return (
<SettingsSection
legacy={false}
heading={
<SettingsHeader
hasRecommendedTag={isEnabled === false}
label={_t("settings|encryption|key_storage|title")}
/>
}
subHeading={_t("settings|encryption|key_storage|description", undefined, {
a: (sub) => (
<a href="https://element.io/help#encryption5" target="_blank" rel="noreferrer noopener">
{sub}
</a>
),
})}
>
<Root className="mx_KeyBackupPanel_toggleRow">
<InlineField
name="keyStorage"
control={<ToggleControl name="keyStorage" checked={isEnabled} onChange={onKeyBackupChange} />}
>
<Label>{_t("settings|encryption|key_storage|allow_key_storage")}</Label>
</InlineField>
{busy && <InlineSpinner />}
</Root>
</SettingsSection>
);
};

View File

@@ -15,7 +15,6 @@ import { _t } from "../../../../languageHandler";
import { EncryptionCard } from "./EncryptionCard";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { uiAuthCallback } from "../../../../CreateCrossSigning";
import { EncryptionCardButtons } from "./EncryptionCardButtons";
interface ResetIdentityPanelProps {
/**
@@ -59,9 +58,9 @@ export function ResetIdentityPanel({ onCancelClick, onFinish, variant }: ResetId
? _t("settings|encryption|advanced|breadcrumb_title_forgot")
: _t("settings|encryption|advanced|breadcrumb_title")
}
className="mx_ResetIdentityPanel"
className="mx_DestructiveComponent"
>
<div className="mx_ResetIdentityPanel_content">
<div className="mx_DestructiveComponent_content">
<VisualList>
<VisualListItem Icon={CheckIcon} success={true}>
{_t("settings|encryption|advanced|breadcrumb_first_description")}
@@ -75,7 +74,7 @@ export function ResetIdentityPanel({ onCancelClick, onFinish, variant }: ResetId
</VisualList>
{variant === "compromised" && <span>{_t("settings|encryption|advanced|breadcrumb_warning")}</span>}
</div>
<EncryptionCardButtons>
<div className="mx_DestructiveComponent_footer">
<Button
destructive={true}
onClick={async (evt) => {
@@ -90,7 +89,7 @@ export function ResetIdentityPanel({ onCancelClick, onFinish, variant }: ResetId
<Button kind="tertiary" onClick={onCancelClick}>
{_t("action|cancel")}
</Button>
</EncryptionCardButtons>
</div>
</EncryptionCard>
</>
);

View File

@@ -8,7 +8,9 @@
import React, { type JSX, useCallback, useEffect, useState } from "react";
import { Button, InlineSpinner, Separator } from "@vector-im/compound-web";
import ComputerIcon from "@vector-im/compound-design-tokens/assets/web/icons/computer";
import { ClientEvent } from "matrix-js-sdk/src/matrix";
import type { MatrixEvent } from "matrix-js-sdk/src/matrix";
import SettingsTab from "../SettingsTab";
import { RecoveryPanel } from "../../encryption/RecoveryPanel";
import { ChangeRecoveryKey } from "../../encryption/ChangeRecoveryKey";
@@ -21,6 +23,9 @@ import { SettingsSubheader } from "../../SettingsSubheader";
import { AdvancedPanel } from "../../encryption/AdvancedPanel";
import { ResetIdentityPanel } from "../../encryption/ResetIdentityPanel";
import { RecoveryPanelOutOfSync } from "../../encryption/RecoveryPanelOutOfSync";
import { useTypedEventEmitter } from "../../../../../hooks/useEventEmitter";
import { KeyStoragePanel } from "../../encryption/KeyStoragePanel";
import { DeleteKeyStoragePanel } from "../../encryption/DeleteKeyStoragePanel";
/**
* The state in the encryption settings tab.
@@ -34,8 +39,10 @@ import { RecoveryPanelOutOfSync } from "../../encryption/RecoveryPanelOutOfSync"
* This happens when the user doesn't have a key a recovery key and the user clicks on "Set up recovery key" button of the RecoveryPanel.
* - "reset_identity_compromised": The panel to show when the user is resetting their identity, in te case where their key is compromised.
* - "reset_identity_forgot": The panel to show when the user is resetting their identity, in the case where they forgot their recovery key.
* - `secrets_not_cached`: The secrets are not cached locally. This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
* - "secrets_not_cached": The secrets are not cached locally. This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
* If the "set_up_encryption" and "secrets_not_cached" conditions are both filled, "set_up_encryption" prevails.
* - "key_storage_delete": The confirmation page asking if the user realy wants to turn off key storage
* - "key_storage_disabled": The user has chosen to disable key storage and options are unavailable as a result.
*/
export type State =
| "loading"
@@ -45,7 +52,9 @@ export type State =
| "set_recovery_key"
| "reset_identity_compromised"
| "reset_identity_forgot"
| "secrets_not_cached";
| "secrets_not_cached"
| "key_storage_delete"
| "key_storage_disabled";
interface EncryptionUserSettingsTabProps {
/**
@@ -63,6 +72,7 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Encrypti
const checkEncryptionState = useCheckEncryptionState(state, setState);
let content: JSX.Element;
switch (state) {
case "loading":
content = <InlineSpinner aria-label={_t("common|loading")} />;
@@ -78,16 +88,23 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Encrypti
/>
);
break;
case "key_storage_disabled":
case "main":
content = (
<>
<RecoveryPanel
onChangeRecoveryKeyClick={(setupNewKey) =>
setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key")
}
/>
<KeyStoragePanel onKeyStorageDisableClick={() => setState("key_storage_delete")} />
<Separator kind="section" />
<AdvancedPanel onResetIdentityClick={() => setState("reset_identity_compromised")} />
{state === "main" && (
<>
<RecoveryPanel
onChangeRecoveryKeyClick={(setupNewKey) =>
setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key")
}
/>
<Separator kind="section" />
</>
)}
<AdvancedPanel onResetIdentityClick={() => setState("reset_identity_compromised")} />{" "}
</>
);
break;
@@ -111,6 +128,9 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Encrypti
/>
);
break;
case "key_storage_delete":
content = <DeleteKeyStoragePanel onFinish={() => setState("main")} />;
break;
}
return (
@@ -124,6 +144,7 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Encrypti
* Hook to check if the user needs:
* - to go through the SetupEncryption flow.
* - to enter their recovery key, if the secrets are not cached locally.
* ...and also whether key backup is enabled.
*
* If the user needs to set up the encryption, the state will be set to "set_up_encryption".
* If the user secrets are not cached, the state will be set to "secrets_not_cached".
@@ -146,8 +167,14 @@ function useCheckEncryptionState(state: State, setState: (state: State) => void)
const cachedSecrets = (await crypto.getCrossSigningStatus()).privateKeysCachedLocally;
const secretsOk = cachedSecrets.masterKey && cachedSecrets.selfSigningKey && cachedSecrets.userSigningKey;
if (isCrossSigningReady && secretsOk) setState("main");
// Also check the key backup status
const backupInfo = await crypto.getKeyBackupInfo();
const keyStorageEnabled = Boolean(backupInfo?.version);
if (isCrossSigningReady && keyStorageEnabled && secretsOk) setState("main");
else if (!isCrossSigningReady) setState("set_up_encryption");
else if (!keyStorageEnabled) setState("key_storage_disabled");
else setState("secrets_not_cached");
}, [matrixClient, setState]);
@@ -156,6 +183,14 @@ function useCheckEncryptionState(state: State, setState: (state: State) => void)
if (state === "loading") checkEncryptionState();
}, [checkEncryptionState, state]);
useTypedEventEmitter(matrixClient, ClientEvent.AccountData, (event: MatrixEvent): void => {
const type = event.getType();
// Recheck the status if this account data has been updated as this implies it has changed
if (type === "m.org.matrix.custom.backup_disabled") {
checkEncryptionState();
}
});
// Also return the callback so that the component can re-run the logic.
return checkEncryptionState;
}

View File

@@ -72,9 +72,6 @@ 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>
@@ -112,43 +109,33 @@ const SidebarUserSettingsTab: React.FC = () => {
</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={!!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}

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