Compare commits
45 Commits
v1.11.93
...
dbkr/key_s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0162c82673 | ||
|
|
a5cec9e59e | ||
|
|
98edffac98 | ||
|
|
96a70f24a6 | ||
|
|
d1aef9f09a | ||
|
|
c4525b97b6 | ||
|
|
de183113e2 | ||
|
|
f0d9e05f85 | ||
|
|
9c4625d6a1 | ||
|
|
0c5f2b07b4 | ||
|
|
64f84cb4f8 | ||
|
|
7149b3d019 | ||
|
|
2ef05c5cb9 | ||
|
|
8ca4a8b6ec | ||
|
|
e8483e0186 | ||
|
|
f586c43a26 | ||
|
|
cfd55a6887 | ||
|
|
7c2d9f4954 | ||
|
|
1178d77fb8 | ||
|
|
25f8fe2009 | ||
|
|
fc9bc0903c | ||
|
|
f818d6e600 | ||
|
|
16c76cb20d | ||
|
|
40f9bd9480 | ||
|
|
e70afdb04f | ||
|
|
4a3a37323e | ||
|
|
df4c23bec7 | ||
|
|
4ea6a33497 | ||
|
|
87d44a7792 | ||
|
|
6b238d1fdc | ||
|
|
1b99071dfc | ||
|
|
a26efc58f1 | ||
|
|
130458783f | ||
|
|
5ac200492c | ||
|
|
aa6de76a8b | ||
|
|
e408715335 | ||
|
|
b7b2ea3448 | ||
|
|
6a20703ebc | ||
|
|
98114c8060 | ||
|
|
4db196e6bd | ||
|
|
4ec09f0063 | ||
|
|
97057de900 | ||
|
|
4eb07d4dda | ||
|
|
98950ded60 | ||
|
|
5a74298d66 |
5
.github/CODEOWNERS
vendored
@@ -11,11 +11,10 @@
|
||||
/src/stores/SetupEncryptionStore.ts @element-hq/element-crypto-web-reviewers
|
||||
/test/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers
|
||||
/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @element-hq/element-crypto-web-reviewers
|
||||
/src/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
|
||||
/src/src/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
|
||||
/test/unit-tests/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
|
||||
/src/components/views/dialogs/devtools/Crypto.tsx @element-hq/element-crypto-web-reviewers
|
||||
/playwright/e2e/crypto/ @element-hq/element-crypto-web-reviewers
|
||||
/playwright/e2e/settings/encryption-user-tab/ @element-hq/element-crypto-web-reviewers
|
||||
/src/components/views/dialogs/devtools/Crypto.tsx @element-hq/element-crypto-web-reviewers
|
||||
|
||||
# Ignore translations as those will be updated by GHA for Localazy download
|
||||
/src/i18n/strings
|
||||
|
||||
@@ -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 }}
|
||||
2
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
63
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -66,18 +66,6 @@ on other runtimes may require root privileges. To resolve this, either run the
|
||||
image as root (`docker run --user 0`) or, better, change the port that nginx
|
||||
listens on via the `ELEMENT_WEB_PORT` environment variable.
|
||||
|
||||
[Element Web Modules](https://github.com/element-hq/element-modules/tree/main/packages/element-web-module-api) can be dynamically loaded
|
||||
by being made available (e.g. via bind mount) in a directory within `/tmp/element-web-modules/`.
|
||||
The default entrypoint will be index.js in that directory but can be overridden if a package.json file is found with a `main` directive.
|
||||
These modules will be presented in a `/modules` subdirectory within the webroot, and automatically added to the config.json `modules` field.
|
||||
|
||||
If you wish to use docker in read-only mode,
|
||||
you should follow the [upstream instructions](https://hub.docker.com/_/nginx#:~:text=Running%20nginx%20in%20read%2Donly%20mode)
|
||||
but additionally include the following directories:
|
||||
|
||||
- /tmp/element-web-config/
|
||||
- /etc/nginx/conf.d/
|
||||
|
||||
The behaviour of the docker image can be customised via the following
|
||||
environment variables:
|
||||
|
||||
|
||||
@@ -112,7 +112,3 @@ Unreliable in encrypted rooms.
|
||||
## Knock rooms (`feature_ask_to_join`) [In Development]
|
||||
|
||||
Enables knock feature for rooms. This allows users to ask to join a room.
|
||||
|
||||
## New room list (`feature_new_room_list`) [In Development]
|
||||
|
||||
Enable the new room list that is currently in development.
|
||||
|
||||
@@ -128,7 +128,7 @@ flowchart TD
|
||||
|
||||
subgraph Deploying
|
||||
D1[\Deploy staging.element.io/]
|
||||
D2[\Check docker build/]
|
||||
D2[\Check dockerhub/]
|
||||
D3[\Deploy app.element.io/]
|
||||
D4[\Check desktop package/]
|
||||
|
||||
@@ -213,10 +213,10 @@ switched back to the version of the dependency from the master branch to not lea
|
||||
# Deploying
|
||||
|
||||
We ship the SDKs to npm, this happens as part of the release process.
|
||||
We ship Element Web to dockerhub, ghcr.io, `*.element.io`, and packages.element.io.
|
||||
We ship Element Web to dockerhub, `*.element.io`, and packages.element.io.
|
||||
We ship Element Desktop to packages.element.io.
|
||||
|
||||
- [ ] Check that element-web has shipped to dockerhub & ghcr.io
|
||||
- [ ] Check that element-web has shipped to dockerhub
|
||||
- [ ] Check that the staging [deployment](https://github.com/element-hq/element-web/actions/workflows/deploy.yml) has completed successfully
|
||||
- [ ] Test staging.element.io
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,7 +11,6 @@ import { registerAccountMas } from "../oidc";
|
||||
import { isDendrite } from "../../plugins/homeserver/dendrite";
|
||||
import { TestClientServerAPI } from "../csAPI";
|
||||
import { masHomeserver } from "../../plugins/homeserver/synapse/masHomeserver.ts";
|
||||
import { checkDeviceIsConnectedKeyBackup } from "./utils";
|
||||
|
||||
// These tests register an account with MAS because then we go through the "normal" registration flow
|
||||
// and crypto gets set up. Using the 'user' fixture create a user and synthesizes an existing login,
|
||||
@@ -25,11 +24,8 @@ test.describe("Encryption state after registration", () => {
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await registerAccountMas(page, mailpitClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!");
|
||||
|
||||
// Wait for the ui to load
|
||||
await expect(page.locator(".mx_MatrixChat")).toBeVisible();
|
||||
|
||||
// Recovery is not set up yet
|
||||
await checkDeviceIsConnectedKeyBackup(app, "1", true, false);
|
||||
await app.settings.openUserSettings("Security & Privacy");
|
||||
await expect(page.getByText("This session is backing up your keys.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("user is prompted to set up recovery", async ({ page, mailpitClient, app }, testInfo) => {
|
||||
|
||||
@@ -58,8 +58,8 @@ test.describe("Backups", () => {
|
||||
|
||||
// Create another
|
||||
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Recovery Key" })).toBeVisible();
|
||||
await currentDialogLocator.getByLabel("Recovery Key").fill(securityKey);
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Security Key" })).toBeVisible();
|
||||
await currentDialogLocator.getByLabel("Security Key").fill(securityKey);
|
||||
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
|
||||
// Should be successful
|
||||
@@ -90,8 +90,8 @@ test.describe("Backups", () => {
|
||||
|
||||
// Try to create another
|
||||
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Recovery Key" })).toBeVisible();
|
||||
// But cancel the recovery key dialog, to simulate not having the secret storage passphrase
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Security Key" })).toBeVisible();
|
||||
// But cancel the security key dialog, to simulate not having the secret storage passphrase
|
||||
await currentDialogLocator.getByTestId("dialog-cancel-button").click();
|
||||
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Starting backup…" })).toBeVisible();
|
||||
|
||||
@@ -186,7 +186,7 @@ test.describe("Cryptography", function () {
|
||||
await page.getByRole("button", { name: "Clear cross-signing keys" }).click();
|
||||
|
||||
// Enter the 4S key
|
||||
await page.getByPlaceholder("Recovery Key").fill(secretStorageKey);
|
||||
await page.getByPlaceholder("Security Key").fill(secretStorageKey);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Enter the password
|
||||
|
||||
@@ -6,14 +6,21 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Locator, type Page } from "@playwright/test";
|
||||
|
||||
import { test, expect } from "../../element-web-test";
|
||||
import { viewRoomSummaryByName } from "../right-panel/utils";
|
||||
import { isDendrite } from "../../plugins/homeserver/dendrite";
|
||||
import { completeCreateSecretStorageDialog, createBot, logIntoElement } from "./utils.ts";
|
||||
import { type Client } from "../../pages/client.ts";
|
||||
import { type ElementAppPage } from "../../pages/ElementAppPage.ts";
|
||||
|
||||
const ROOM_NAME = "Test room";
|
||||
const NAME = "Alice";
|
||||
|
||||
function getMemberTileByName(page: Page, name: string): Locator {
|
||||
return page.locator(`.mx_MemberTileView, [title="${name}"]`);
|
||||
}
|
||||
|
||||
test.use({
|
||||
displayName: NAME,
|
||||
synapseConfig: {
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
logIntoElement,
|
||||
logOutOfElement,
|
||||
verify,
|
||||
waitForDevices,
|
||||
} from "./utils";
|
||||
import { bootstrapCrossSigningForClient } from "../../pages/client.ts";
|
||||
import { type ElementAppPage } from "../../pages/ElementAppPage.ts";
|
||||
@@ -145,8 +144,25 @@ test.describe("Cryptography", function () {
|
||||
// bob deletes his second device
|
||||
await bobSecondDevice.evaluate((cli) => cli.logout(true));
|
||||
|
||||
// wait for the logout to propagate.
|
||||
await waitForDevices(app, bob.credentials.userId, 1);
|
||||
// wait for the logout to propagate. Workaround for https://github.com/vector-im/element-web/issues/26263 by repeatedly closing and reopening Bob's user info.
|
||||
async function awaitOneDevice(iterations = 1) {
|
||||
const rightPanel = page.locator(".mx_RightPanel");
|
||||
await rightPanel.getByTestId("base-card-back-button").click();
|
||||
await rightPanel.getByText("Bob").click();
|
||||
const sessionCountText = await rightPanel
|
||||
.locator(".mx_UserInfo_devices")
|
||||
.getByText(" session", { exact: false })
|
||||
.textContent();
|
||||
// cf https://github.com/vector-im/element-web/issues/26279: Element-R uses the wrong text here
|
||||
if (sessionCountText != "1 session" && sessionCountText != "1 verified session") {
|
||||
if (iterations >= 10) {
|
||||
throw new Error(`Bob still has ${sessionCountText} after 10 iterations`);
|
||||
}
|
||||
await awaitOneDevice(iterations + 1);
|
||||
}
|
||||
}
|
||||
|
||||
await awaitOneDevice();
|
||||
|
||||
// close and reopen the room, to get the shield to update.
|
||||
await app.viewRoomByName("Bob");
|
||||
@@ -269,7 +285,11 @@ test.describe("Cryptography", function () {
|
||||
// Workaround for https://github.com/element-hq/element-web/issues/28640:
|
||||
// make sure that Alice has seen Bob's identity before she goes offline. We do this by opening
|
||||
// his user info.
|
||||
await waitForDevices(app, bob.credentials.userId, 1);
|
||||
await app.toggleRoomInfoPanel();
|
||||
const rightPanel = page.locator(".mx_RightPanel");
|
||||
await rightPanel.getByRole("menuitem", { name: "People" }).click();
|
||||
await rightPanel.getByRole("button", { name: bob.credentials!.userId }).click();
|
||||
await expect(rightPanel.locator(".mx_UserInfo_devices")).toContainText("1 session");
|
||||
|
||||
// Our app is blocked from syncing while Bob sends his messages.
|
||||
await app.client.network.goOffline();
|
||||
|
||||
@@ -35,7 +35,7 @@ test.describe("Key storage out of sync toast", () => {
|
||||
|
||||
await page.getByRole("button", { name: "Enter recovery key" }).click();
|
||||
|
||||
await page.getByRole("textbox", { name: "Recovery Key" }).fill(recoveryKey.encodedPrivateKey);
|
||||
await page.getByRole("textbox", { name: "Security key" }).fill(recoveryKey.encodedPrivateKey);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Enter recovery key" })).not.toBeVisible();
|
||||
|
||||
@@ -8,8 +8,9 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import { type Preset, type Visibility } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../../element-web-test";
|
||||
import { doTwoWaySasVerification, awaitVerifier, waitForDevices } from "./utils";
|
||||
import { doTwoWaySasVerification, awaitVerifier } from "./utils";
|
||||
import { type Client } from "../../pages/client";
|
||||
|
||||
test.describe("User verification", () => {
|
||||
@@ -32,17 +33,13 @@ test.describe("User verification", () => {
|
||||
});
|
||||
|
||||
test("can receive a verification request when there is no existing DM", async ({
|
||||
app,
|
||||
page,
|
||||
bot: bob,
|
||||
user: aliceCredentials,
|
||||
toasts,
|
||||
room: { roomId: dmRoomId },
|
||||
}) => {
|
||||
await waitForDevices(app, bob.credentials.userId, 1);
|
||||
await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible();
|
||||
const avatar = page.getByRole("button", { name: "Avatar" });
|
||||
await avatar.click();
|
||||
await waitForDeviceKeys(page);
|
||||
|
||||
// once Alice has joined, Bob starts the verification
|
||||
const bobVerificationRequest = await bob.evaluateHandle(
|
||||
@@ -87,17 +84,13 @@ test.describe("User verification", () => {
|
||||
});
|
||||
|
||||
test("can abort emoji verification when emoji mismatch", async ({
|
||||
app,
|
||||
page,
|
||||
bot: bob,
|
||||
user: aliceCredentials,
|
||||
toasts,
|
||||
room: { roomId: dmRoomId },
|
||||
}) => {
|
||||
await waitForDevices(app, bob.credentials.userId, 1);
|
||||
await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible();
|
||||
const avatar = page.getByRole("button", { name: "Avatar" });
|
||||
await avatar.click();
|
||||
await waitForDeviceKeys(page);
|
||||
|
||||
// once Alice has joined, Bob starts the verification
|
||||
const bobVerificationRequest = await bob.evaluateHandle(
|
||||
@@ -161,3 +154,15 @@ async function createDMRoom(client: Client, userId: string): Promise<string> {
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until we get the other user's device keys.
|
||||
* In newer rust-crypto versions, the verification request will be ignored if we
|
||||
* don't have the sender's device keys.
|
||||
*/
|
||||
async function waitForDeviceKeys(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible();
|
||||
const avatar = await page.getByRole("button", { name: "Avatar" });
|
||||
await avatar.click();
|
||||
await expect(page.getByText("1 session")).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -145,13 +145,11 @@ export async function checkDeviceIsCrossSigned(app: ElementAppPage): Promise<voi
|
||||
* @param app -` ElementAppPage` wrapper for the playwright `Page`.
|
||||
* @param expectedBackupVersion - the version of the backup we expect to be connected to.
|
||||
* @param checkBackupPrivateKeyInCache - whether to check that the backup decryption key is cached locally
|
||||
* @param checkBackupKeyIn4S - whether to check that the backup key is stored in 4S
|
||||
*/
|
||||
export async function checkDeviceIsConnectedKeyBackup(
|
||||
app: ElementAppPage,
|
||||
expectedBackupVersion: string,
|
||||
checkBackupPrivateKeyInCache: boolean,
|
||||
checkBackupKeyIn4S: boolean = true,
|
||||
): Promise<void> {
|
||||
// Sanity check the given backup version: if it's null, something went wrong earlier in the test.
|
||||
if (!expectedBackupVersion) {
|
||||
@@ -194,7 +192,7 @@ export async function checkDeviceIsConnectedKeyBackup(
|
||||
// The active backup version is as expected
|
||||
expect(activeBackupVersion).toBe(expectedBackupVersion);
|
||||
// The backup key is stored in 4S
|
||||
if (checkBackupKeyIn4S) expect(backupKeyIn4S).toBe(true);
|
||||
expect(backupKeyIn4S).toBe(true);
|
||||
|
||||
if (checkBackupPrivateKeyInCache) {
|
||||
// The backup key is available locally
|
||||
@@ -218,13 +216,13 @@ export async function logIntoElement(page: Page, credentials: Credentials, secur
|
||||
|
||||
// if a securityKey was given, verify the new device
|
||||
if (securityKey !== undefined) {
|
||||
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key" }).click();
|
||||
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key" }).click();
|
||||
|
||||
const useSecurityKey = page.locator(".mx_Dialog").getByRole("button", { name: "use your Recovery Key" });
|
||||
const useSecurityKey = page.locator(".mx_Dialog").getByRole("button", { name: "use your Security Key" });
|
||||
if (await useSecurityKey.isVisible()) {
|
||||
await useSecurityKey.click();
|
||||
}
|
||||
// Fill in the recovery key
|
||||
// Fill in the security key
|
||||
await page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey);
|
||||
await page.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
|
||||
await page.getByRole("button", { name: "Done" }).click();
|
||||
@@ -251,15 +249,15 @@ export async function logOutOfElement(page: Page, discardKeys: boolean = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the encryption settings, and verify the current session using the recovery key.
|
||||
* Open the encryption settings, and verify the current session using the security key.
|
||||
*
|
||||
* @param app - `ElementAppPage` wrapper for the playwright `Page`.
|
||||
* @param securityKey - The recovery key (i.e., 4S key), set up during a previous session.
|
||||
* @param securityKey - The security key (i.e., 4S key), set up during a previous session.
|
||||
*/
|
||||
export async function verifySession(app: ElementAppPage, securityKey: string) {
|
||||
const settings = await app.settings.openUserSettings("Encryption");
|
||||
await settings.getByRole("button", { name: "Verify this device" }).click();
|
||||
await app.page.getByRole("button", { name: "Verify with Recovery Key" }).click();
|
||||
await app.page.getByRole("button", { name: "Verify with Security Key" }).click();
|
||||
await app.page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey);
|
||||
await app.page.getByRole("button", { name: "Continue", disabled: false }).click();
|
||||
await app.page.getByRole("button", { name: "Done" }).click();
|
||||
@@ -293,7 +291,7 @@ export async function doTwoWaySasVerification(page: Page, verifier: JSHandle<Ver
|
||||
*
|
||||
* Assumes that the current device has been cross-signed (which means that we skip a step where we set it up).
|
||||
*
|
||||
* Returns the recovery key
|
||||
* Returns the security key
|
||||
*/
|
||||
export async function enableKeyBackup(app: ElementAppPage): Promise<string> {
|
||||
await app.settings.openUserSettings("Security & Privacy");
|
||||
@@ -321,9 +319,9 @@ export async function completeCreateSecretStorageDialog(
|
||||
const currentDialogLocator = page.locator(".mx_Dialog");
|
||||
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible();
|
||||
// "Generate a Recovery Key" is selected by default
|
||||
// "Generate a Security Key" is selected by default
|
||||
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Save your Recovery Key" })).toBeVisible();
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible();
|
||||
await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click();
|
||||
// copy the recovery key to use it later
|
||||
const recoveryKey = await page.evaluate(() => navigator.clipboard.readText());
|
||||
@@ -347,7 +345,7 @@ export async function completeCreateSecretStorageDialog(
|
||||
}
|
||||
|
||||
/**
|
||||
* Click on copy and continue buttons to dismiss the recovery key dialog
|
||||
* Click on copy and continue buttons to dismiss the security key dialog
|
||||
*/
|
||||
export async function copyAndContinue(page: Page) {
|
||||
await page.getByRole("button", { name: "Copy" }).click();
|
||||
@@ -504,31 +502,3 @@ export async function deleteCachedSecrets(page: Page) {
|
||||
});
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the given user has a given number of devices.
|
||||
* This function will check the device keys ten times and if
|
||||
* the expected number of devices were not found by then, an
|
||||
* error is thrown.
|
||||
*/
|
||||
export async function waitForDevices(
|
||||
app: ElementAppPage,
|
||||
userId: string,
|
||||
expectedNumberOfDevices: number,
|
||||
): Promise<void> {
|
||||
const result = await app.client.evaluate(
|
||||
async (cli, { userId, expectedNumberOfDevices }) => {
|
||||
for (let i = 0; i < 10; ++i) {
|
||||
const userDeviceMap = await cli.getCrypto()?.getUserDeviceInfo([userId], true);
|
||||
const deviceMap = userDeviceMap?.get(userId);
|
||||
if (deviceMap.size === expectedNumberOfDevices) return true;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ userId, expectedNumberOfDevices },
|
||||
);
|
||||
if (!result) {
|
||||
throw new Error(`User ${userId} did not have ${expectedNumberOfDevices} devices within ten iterations!`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 ({
|
||||
|
||||
@@ -111,4 +111,21 @@ test.describe("Encryption tab", () => {
|
||||
// The user is prompted to reset their identity
|
||||
await expect(dialog.getByText("Forgot your recovery key? You’ll need to reset your identity.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should warn before turning off key storage", { tag: "@screenshot" }, async ({ page, app, util }) => {
|
||||
await verifySession(app, recoveryKey.encodedPrivateKey);
|
||||
await util.openEncryptionTab();
|
||||
|
||||
await page.getByRole("checkbox", { name: "Allow key storage" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Are you sure you want to turn off key storage and delete it?" }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(util.getEncryptionTabContent()).toMatchScreenshot("delete-key-storage-confirm.png");
|
||||
|
||||
await page.getByRole("button", { name: "Delete key storage" }).click();
|
||||
|
||||
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@ class Helpers {
|
||||
*/
|
||||
async verifyDevice(recoveryKey: GeneratedSecretStorageKey) {
|
||||
// Select the security phrase
|
||||
await this.page.getByRole("button", { name: "Verify with Recovery Key" }).click();
|
||||
await this.page.getByRole("button", { name: "Verify with Security Key" }).click();
|
||||
await this.enterRecoveryKey(recoveryKey);
|
||||
await this.page.getByRole("button", { name: "Done" }).click();
|
||||
}
|
||||
@@ -91,7 +91,7 @@ class Helpers {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the recovery key from the clipboard and fill in the input field
|
||||
* Get the security key from the clipboard and fill in the input field
|
||||
* Then click on the finish button
|
||||
* @param title - The title of the dialog
|
||||
* @param confirmButtonLabel - The label of the confirm button
|
||||
|
||||
@@ -25,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
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ test.describe("UserView", () => {
|
||||
|
||||
const rightPanel = page.locator("#mx_RightPanel");
|
||||
await expect(rightPanel.getByRole("heading", { name: bot.credentials.displayName, exact: true })).toBeVisible();
|
||||
await expect(rightPanel.getByText("1 session")).toBeVisible();
|
||||
await expect(rightPanel).toMatchScreenshot("user-info.png", {
|
||||
mask: [page.locator(".mx_UserInfo_profile_mxid")],
|
||||
css: `
|
||||
|
||||
@@ -202,15 +202,4 @@ export class ElementAppPage {
|
||||
}
|
||||
return this.page.locator(`id=${labelledById ?? describedById}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the notification toast
|
||||
*/
|
||||
public closeNotificationToast(): Promise<void> {
|
||||
// Dismiss "Notification" toast
|
||||
return this.page
|
||||
.locator(".mx_Toast_toast", { hasText: "Notifications" })
|
||||
.getByRole("button", { name: "Dismiss" })
|
||||
.click();
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.mx_KeyBackupPanel_toggleRow {
|
||||
flex-direction: row;
|
||||
}
|
||||
@@ -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
|
||||
* {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_EncryptionCard_buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-4x);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
73
src/components/views/settings/encryption/KeyStoragePanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||