Compare commits
152 Commits
t3chguy/fi
...
hs/add-cus
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eb5e8965a | ||
|
|
6d2f1c2e9a | ||
|
|
f44308b9a9 | ||
|
|
bba40ca706 | ||
|
|
706b33fcf4 | ||
|
|
a7a8428d1c | ||
|
|
96797c3524 | ||
|
|
01519f7fd5 | ||
|
|
ba3b9840ca | ||
|
|
66e73818a8 | ||
|
|
d97d999ef2 | ||
|
|
9d1455e4dd | ||
|
|
294857209d | ||
|
|
391bd15258 | ||
|
|
42edbab715 | ||
|
|
5aee224169 | ||
|
|
ff986e4317 | ||
|
|
28a232eea8 | ||
|
|
a2bea649f6 | ||
|
|
1e3fd9d3aa | ||
|
|
0f0f904cb0 | ||
|
|
d89afe83a8 | ||
|
|
6f0d288c1d | ||
|
|
c6d4f38a04 | ||
|
|
62f62601ef | ||
|
|
e60a68ea1a | ||
|
|
ec13bdc910 | ||
|
|
c0d91a46c7 | ||
|
|
55e874fb50 | ||
|
|
a622772a08 | ||
|
|
389a0e689e | ||
|
|
451a99d49e | ||
|
|
ed9b480338 | ||
|
|
82200b57cf | ||
|
|
fadaaccebc | ||
|
|
e293d2b58f | ||
|
|
f43e953794 | ||
|
|
276fa5eaa8 | ||
|
|
bd4509576c | ||
|
|
10b9b2cb8b | ||
|
|
d770826c2d | ||
|
|
c995496a93 | ||
|
|
902517a02d | ||
|
|
e28b197868 | ||
|
|
2350c065a4 | ||
|
|
b218b103b3 | ||
|
|
67bd11c904 | ||
|
|
05ffa2e5ba | ||
|
|
c51823db5e | ||
|
|
5b51fe48af | ||
|
|
0e748710cd | ||
|
|
3f6d900627 | ||
|
|
eb7359403f | ||
|
|
7fe53eac16 | ||
|
|
16773f5e4a | ||
|
|
e7d940160a | ||
|
|
a333856c50 | ||
|
|
9136d841ee | ||
|
|
d638691fbd | ||
|
|
6103f7e3b4 | ||
|
|
2b24232f14 | ||
|
|
3e8599bba0 | ||
|
|
073606207e | ||
|
|
7eb16b3361 | ||
|
|
e5d167dcf3 | ||
|
|
140afea791 | ||
|
|
ad71e7bdc4 | ||
|
|
311c038fe1 | ||
|
|
f740dc3829 | ||
|
|
2b1a4e007c | ||
|
|
231ab20dcf | ||
|
|
df4cf64ebe | ||
|
|
b9f319a9f5 | ||
|
|
9c0604f849 | ||
|
|
f97df3eb3b | ||
|
|
114fd6d123 | ||
|
|
7bb49c567d | ||
|
|
b2258a93b4 | ||
|
|
dba4952721 | ||
|
|
5cf543a9a7 | ||
|
|
ce428b5e2d | ||
|
|
757e4e1395 | ||
|
|
1b2d9b392c | ||
|
|
b9b31fa0fb | ||
|
|
7d69ce39d9 | ||
|
|
c6445bbc2c | ||
|
|
1c5bc4a7be | ||
|
|
6bc117993d | ||
|
|
713cd472c6 | ||
|
|
7eb133286b | ||
|
|
7eb1433f32 | ||
|
|
ce75b9da09 | ||
|
|
2e8791c651 | ||
|
|
52794501f4 | ||
|
|
fd9b981852 | ||
|
|
f85d0c95b8 | ||
|
|
ff26b9e89d | ||
|
|
013f5a0c91 | ||
|
|
e92bf78289 | ||
|
|
f119b93e79 | ||
|
|
5aecdebbc7 | ||
|
|
7d8f0c7832 | ||
|
|
fcfcd29ec7 | ||
|
|
79e71fe3a0 | ||
|
|
ae9e85e360 | ||
|
|
e078dc114b | ||
|
|
1167776745 | ||
|
|
fe760421cd | ||
|
|
331bbc19a6 | ||
|
|
7526f20ea3 | ||
|
|
ee87b0e2d2 | ||
|
|
3fd52c9e07 | ||
|
|
e9c91ba28a | ||
|
|
45182172b8 | ||
|
|
8513eaa898 | ||
|
|
87447c7f91 | ||
|
|
f5125ac2b8 | ||
|
|
bd142412e5 | ||
|
|
581920e82b | ||
|
|
5d2d4947f4 | ||
|
|
69fe2ad06c | ||
|
|
ed0b50283e | ||
|
|
e7e425f3db | ||
|
|
f81a127d46 | ||
|
|
1ed3f205f3 | ||
|
|
b539eda4fe | ||
|
|
afab6c29dc | ||
|
|
4d81b36270 | ||
|
|
3281a4128f | ||
|
|
22c7bf346c | ||
|
|
213a191b8c | ||
|
|
e1104891cb | ||
|
|
78ec757f11 | ||
|
|
45f41a33e7 | ||
|
|
b56b0f2bd0 | ||
|
|
4dcde7ec7a | ||
|
|
b07225eb60 | ||
|
|
9642af9930 | ||
|
|
c309cc8bfa | ||
|
|
fb65bbf521 | ||
|
|
57d3b2d93c | ||
|
|
aef3c8e986 | ||
|
|
1b48269db5 | ||
|
|
76d7f6ab43 | ||
|
|
69c1a8cd1c | ||
|
|
231515bc6c | ||
|
|
ccd77be74a | ||
|
|
be5dd058b3 | ||
|
|
2326a7c8dc | ||
|
|
c52ec3efd1 | ||
|
|
138c40b0c1 | ||
|
|
85647efadb |
2
.github/workflows/docker.yaml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
fetch-depth: 0 # needed for docker-package to be able to calculate the version
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3
|
||||
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
- name: Set up QEMU
|
||||
|
||||
19
.github/workflows/end-to-end-tests.yaml
vendored
@@ -157,9 +157,8 @@ jobs:
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: |
|
||||
~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ steps.playwright.outputs.version }}
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright.outputs.version }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
@@ -186,9 +185,19 @@ jobs:
|
||||
path: blob-report
|
||||
retention-days: 1
|
||||
|
||||
downstream-modules:
|
||||
name: Downstream Playwright tests [element-modules]
|
||||
needs: build
|
||||
if: inputs.skip != true && github.event_name == 'merge_group'
|
||||
uses: element-hq/element-modules/.github/workflows/reusable-playwright-tests.yml@main
|
||||
with:
|
||||
webapp-artifact: webapp
|
||||
|
||||
complete:
|
||||
name: end-to-end-tests
|
||||
needs: playwright
|
||||
needs:
|
||||
- playwright
|
||||
- downstream-modules
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
@@ -232,5 +241,5 @@ jobs:
|
||||
path: playwright-report
|
||||
retention-days: 14
|
||||
|
||||
- if: needs.playwright.result != 'skipped' && needs.playwright.result != 'success'
|
||||
- if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
||||
run: exit 1
|
||||
|
||||
86
CHANGELOG.md
@@ -1,3 +1,89 @@
|
||||
Changes in [1.11.104](https://github.com/element-hq/element-web/releases/tag/v1.11.104) (2025-06-17)
|
||||
====================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Update the mobile\_guide page to the new design. ([#30006](https://github.com/element-hq/element-web/pull/30006)). Contributed by @pixlwave.
|
||||
* Provide a devtool for manually verifying other devices ([#30094](https://github.com/element-hq/element-web/pull/30094)). Contributed by @andybalaam.
|
||||
* Implement MSC4155: Invite filtering ([#29603](https://github.com/element-hq/element-web/pull/29603)). Contributed by @Half-Shot.
|
||||
* Add low priority avatar decoration to room tile ([#30065](https://github.com/element-hq/element-web/pull/30065)). Contributed by @MidhunSureshR.
|
||||
* Add ability to prevent window content being captured by other apps (Desktop) ([#30098](https://github.com/element-hq/element-web/pull/30098)). Contributed by @t3chguy.
|
||||
* New room list: move message preview in user settings ([#30023](https://github.com/element-hq/element-web/pull/30023)). Contributed by @florianduros.
|
||||
* New room list: change room options icon ([#30029](https://github.com/element-hq/element-web/pull/30029)). Contributed by @florianduros.
|
||||
* RoomListStore: Sort low priority rooms to the bottom of the list ([#30070](https://github.com/element-hq/element-web/pull/30070)). Contributed by @MidhunSureshR.
|
||||
* Add low priority filter pill to the room list UI ([#30060](https://github.com/element-hq/element-web/pull/30060)). Contributed by @MidhunSureshR.
|
||||
* New room list: remove color gradient in space panel ([#29721](https://github.com/element-hq/element-web/pull/29721)). Contributed by @florianduros.
|
||||
* /share?msg=foo endpoint using forward message dialog ([#29874](https://github.com/element-hq/element-web/pull/29874)). Contributed by @ara4n.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Do not send empty auth when setting up cross-signing keys ([#29914](https://github.com/element-hq/element-web/pull/29914)). Contributed by @gnieto.
|
||||
* Settings: flip local video feed by default ([#29501](https://github.com/element-hq/element-web/pull/29501)). Contributed by @jbtrystram.
|
||||
* AccessSecretStorageDialog: various fixes ([#30093](https://github.com/element-hq/element-web/pull/30093)). Contributed by @richvdh.
|
||||
* AccessSecretStorageDialog: fix inability to enter recovery key ([#30090](https://github.com/element-hq/element-web/pull/30090)). Contributed by @richvdh.
|
||||
* Fix failure to upload thumbnail causing image to send as file ([#30086](https://github.com/element-hq/element-web/pull/30086)). Contributed by @t3chguy.
|
||||
* Low priority menu item should be a toggle ([#30071](https://github.com/element-hq/element-web/pull/30071)). Contributed by @MidhunSureshR.
|
||||
* Add sanity checks to prevent users from ignoring themselves ([#30079](https://github.com/element-hq/element-web/pull/30079)). Contributed by @MidhunSureshR.
|
||||
* Fix issue with duplicate images ([#30073](https://github.com/element-hq/element-web/pull/30073)). Contributed by @fatlewis.
|
||||
* Handle errors returned from Seshat ([#30083](https://github.com/element-hq/element-web/pull/30083)). Contributed by @richvdh.
|
||||
|
||||
|
||||
Changes in [1.11.103](https://github.com/element-hq/element-web/releases/tag/v1.11.103) (2025-06-10)
|
||||
====================================================================================================
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
+ Check the sender of an event matches owner of session, preventing sender spoofing by homeserver owners.
|
||||
[13c1d20](https://github.com/matrix-org/matrix-rust-sdk/commit/13c1d2048286bbabf5e7bc6b015aafee98f04d55) (High, [GHSA-x958-rvg6-956w](https://github.com/matrix-org/matrix-rust-sdk/security/advisories/GHSA-x958-rvg6-956w)).
|
||||
|
||||
Changes in [1.11.102](https://github.com/element-hq/element-web/releases/tag/v1.11.102) (2025-06-03)
|
||||
====================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* EW: Modernize the recovery key input modal ([#29819](https://github.com/element-hq/element-web/pull/29819)). Contributed by @uhoreg.
|
||||
* New room list: move secondary filters into primary filters ([#29972](https://github.com/element-hq/element-web/pull/29972)). Contributed by @florianduros.
|
||||
* Prompt the user when key storage is unexpectedly off ([#29912](https://github.com/element-hq/element-web/pull/29912)). Contributed by @andybalaam.
|
||||
* New room list: move sort menu in room list header ([#29983](https://github.com/element-hq/element-web/pull/29983)). Contributed by @florianduros.
|
||||
* New room list: rework spacing of room list item ([#29965](https://github.com/element-hq/element-web/pull/29965)). Contributed by @florianduros.
|
||||
* RLS: Remove forgotten room from skiplist ([#29933](https://github.com/element-hq/element-web/pull/29933)). Contributed by @MidhunSureshR.
|
||||
* Add room list sorting ([#29951](https://github.com/element-hq/element-web/pull/29951)). Contributed by @dbkr.
|
||||
* Don't use the minimised width(68px) on the new room list ([#29778](https://github.com/element-hq/element-web/pull/29778)). Contributed by @langleyd.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* [Backport staging] Close call options popup menu when option has been selected ([#30054](https://github.com/element-hq/element-web/pull/30054)). Contributed by @RiotRobot.
|
||||
* RoomListStoreV3: Only add new rooms that pass `VisibilityProvider` check ([#29974](https://github.com/element-hq/element-web/pull/29974)). Contributed by @MidhunSureshR.
|
||||
* Re-order primary filters ([#29957](https://github.com/element-hq/element-web/pull/29957)). Contributed by @dbkr.
|
||||
* Fix leaky CSS adding `!` to all H1 elements ([#29964](https://github.com/element-hq/element-web/pull/29964)). Contributed by @t3chguy.
|
||||
* Fix extensions panel style ([#29273](https://github.com/element-hq/element-web/pull/29273)). Contributed by @langleyd.
|
||||
* Fix state events being hidden from widgets in read\_events actions ([#29954](https://github.com/element-hq/element-web/pull/29954)). Contributed by @robintown.
|
||||
* Remove old filter test ([#29963](https://github.com/element-hq/element-web/pull/29963)). Contributed by @dbkr.
|
||||
|
||||
|
||||
Changes in [1.11.101](https://github.com/element-hq/element-web/releases/tag/v1.11.101) (2025-05-20)
|
||||
====================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* New room list: add keyboard navigation support ([#29805](https://github.com/element-hq/element-web/pull/29805)). Contributed by @florianduros.
|
||||
* Use the JoinRuleSettings component for the guest link access prompt. ([#28614](https://github.com/element-hq/element-web/pull/28614)). Contributed by @toger5.
|
||||
* Add loading state to the new room list view ([#29725](https://github.com/element-hq/element-web/pull/29725)). Contributed by @langleyd.
|
||||
* Make OIDC identity reset consistent with EX ([#29854](https://github.com/element-hq/element-web/pull/29854)). Contributed by @andybalaam.
|
||||
* Support error code for email / phone adding unsupported (MSC4178) ([#29855](https://github.com/element-hq/element-web/pull/29855)). Contributed by @dbkr.
|
||||
* Update identity reset UI (Make consistent with EX) ([#29701](https://github.com/element-hq/element-web/pull/29701)). Contributed by @andybalaam.
|
||||
* Add secondary filters to the new room list ([#29818](https://github.com/element-hq/element-web/pull/29818)). Contributed by @dbkr.
|
||||
* Fix battery drain from Web Audio ([#29203](https://github.com/element-hq/element-web/pull/29203)). Contributed by @mbachry.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix go home shortcut on macos and change toggle action events shortcut ([#29929](https://github.com/element-hq/element-web/pull/29929)). Contributed by @florianduros.
|
||||
* New room list: fix outdated message preview when space or filter change ([#29925](https://github.com/element-hq/element-web/pull/29925)). Contributed by @florianduros.
|
||||
* Stop migrating to MSC4278 if the config exists. ([#29924](https://github.com/element-hq/element-web/pull/29924)). Contributed by @Half-Shot.
|
||||
* Ensure consistent download file name on download from ImageView ([#29913](https://github.com/element-hq/element-web/pull/29913)). Contributed by @t3chguy.
|
||||
* Add error toast when service worker registration fails ([#29895](https://github.com/element-hq/element-web/pull/29895)). Contributed by @t3chguy.
|
||||
* New Room List: Prevent old tombstoned rooms from appearing in the list ([#29881](https://github.com/element-hq/element-web/pull/29881)). Contributed by @MidhunSureshR.
|
||||
* Remove lag in search field ([#29885](https://github.com/element-hq/element-web/pull/29885)). Contributed by @florianduros.
|
||||
* Respect UIFeature.Voip ([#29873](https://github.com/element-hq/element-web/pull/29873)). Contributed by @langleyd.
|
||||
* Allow jumping to message search from spotlight ([#29850](https://github.com/element-hq/element-web/pull/29850)). Contributed by @t3chguy.
|
||||
|
||||
|
||||
Changes in [1.11.100](https://github.com/element-hq/element-web/releases/tag/v1.11.100) (2025-05-06)
|
||||
====================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# syntax=docker.io/docker/dockerfile:1.15-labs@sha256:94edd5b349df43675bd6f542e2b9a24e7177432dec45fe3066bfcf2ab14c4355
|
||||
# syntax=docker.io/docker/dockerfile:1.16-labs@sha256:bb5e2b225985193779991f3256d1901a0b3e6a0b284c7bffa0972064f4a6d458
|
||||
|
||||
# Builder
|
||||
FROM --platform=$BUILDPLATFORM node:22-bullseye@sha256:ed0338dd02fd86861a59dc1cbc2e12152f3a93c4ce5933d347d6677232000dc7 AS builder
|
||||
FROM --platform=$BUILDPLATFORM node:22-bullseye@sha256:f16d8e8af67bb6361231e932b8b3e7afa040cbfed181719a450b02c3821b26c1 AS builder
|
||||
|
||||
# Support custom branch of the js-sdk. This also helps us build images of element-web develop.
|
||||
ARG USE_CUSTOM_SDKS=false
|
||||
@@ -19,7 +19,7 @@ RUN /src/scripts/docker-package.sh
|
||||
RUN cp /src/config.sample.json /src/webapp/config.json
|
||||
|
||||
# App
|
||||
FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:0a49675e3e35cc2f89ce831f00f767af9c32df04f5a80167739fd32346f1fe99
|
||||
FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:66e34aa81c2faf290ea4e4c28a490f2b35a07478265a2d5994c8637506045eee
|
||||
|
||||
# Need root user to install packages & manipulate the usr directory
|
||||
USER root
|
||||
|
||||
@@ -126,7 +126,7 @@ guide](https://classic.yarnpkg.com/en/docs/install) if you do not have it alread
|
||||
1. Install the prerequisites: `yarn install`.
|
||||
- If you're using the `develop` branch, then it is recommended to set up a
|
||||
proper development environment (see [Setting up a dev
|
||||
environment](#setting-up-a-dev-environment) below). Alternatively, you
|
||||
environment](./developer_guide.md#setting-up-a-dev-environment) below). Alternatively, you
|
||||
can use <https://develop.element.io> - the continuous integration release of
|
||||
the develop branch.
|
||||
1. Configure the app by copying `config.sample.json` to `config.json` and
|
||||
|
||||
29
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "element-web",
|
||||
"version": "1.11.100",
|
||||
"version": "1.11.104",
|
||||
"description": "Element: the future of secure communication",
|
||||
"author": "New Vector Ltd.",
|
||||
"repository": {
|
||||
@@ -70,18 +70,18 @@
|
||||
"resolutions": {
|
||||
"**/pretty-format/react-is": "19.1.0",
|
||||
"@playwright/test": "1.52.0",
|
||||
"@types/react": "19.1.4",
|
||||
"@types/react-dom": "19.1.5",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"oidc-client-ts": "3.2.1",
|
||||
"jwt-decode": "4.0.0",
|
||||
"caniuse-lite": "1.0.30001717",
|
||||
"testcontainers": "10.25.0",
|
||||
"caniuse-lite": "1.0.30001721",
|
||||
"testcontainers": "^11.0.0",
|
||||
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
|
||||
"wrap-ansi": "npm:wrap-ansi@^7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@element-hq/element-web-module-api": "1.0.0",
|
||||
"@element-hq/element-web-module-api": "1.2.0",
|
||||
"@fontsource/inconsolata": "^5",
|
||||
"@fontsource/inter": "^5",
|
||||
"@formatjs/intl-segmenter": "^11.5.7",
|
||||
@@ -93,7 +93,7 @@
|
||||
"@types/png-chunks-extract": "^1.0.2",
|
||||
"@types/react-virtualized": "^9.21.30",
|
||||
"@vector-im/compound-design-tokens": "^4.0.0",
|
||||
"@vector-im/compound-web": "^7.10.2",
|
||||
"@vector-im/compound-web": "^8.0.0",
|
||||
"@vector-im/matrix-wysiwyg": "2.38.3",
|
||||
"@zxcvbn-ts/core": "^3.0.4",
|
||||
"@zxcvbn-ts/language-common": "^3.0.4",
|
||||
@@ -138,7 +138,7 @@
|
||||
"opus-recorder": "^8.0.3",
|
||||
"pako": "^2.0.3",
|
||||
"png-chunks-extract": "^1.0.0",
|
||||
"posthog-js": "1.240.6",
|
||||
"posthog-js": "1.249.4",
|
||||
"qrcode": "1.5.4",
|
||||
"re-resizable": "6.11.2",
|
||||
"react": "^19.0.0",
|
||||
@@ -151,7 +151,7 @@
|
||||
"react-virtualized": "^9.22.5",
|
||||
"rfc4648": "^1.4.0",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"sanitize-html": "2.16.0",
|
||||
"sanitize-html": "2.17.0",
|
||||
"tar-js": "^0.3.0",
|
||||
"temporal-polyfill": "^0.3.0",
|
||||
"ua-parser-js": "^1.0.2",
|
||||
@@ -180,7 +180,7 @@
|
||||
"@babel/preset-typescript": "^7.12.7",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@casualbot/jest-sonar-reporter": "2.2.7",
|
||||
"@element-hq/element-call-embedded": "0.10.0",
|
||||
"@element-hq/element-call-embedded": "0.12.2",
|
||||
"@element-hq/element-web-playwright-common": "^1.1.5",
|
||||
"@peculiar/webcrypto": "^1.4.3",
|
||||
"@playwright/test": "^1.50.1",
|
||||
@@ -212,9 +212,9 @@
|
||||
"@types/node-fetch": "^2.6.2",
|
||||
"@types/pako": "^2.0.0",
|
||||
"@types/qrcode": "^1.3.5",
|
||||
"@types/react": "19.1.4",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/react-beautiful-dnd": "^13.0.0",
|
||||
"@types/react-dom": "19.1.5",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"@types/react-transition-group": "^4.4.0",
|
||||
"@types/sanitize-html": "2.16.0",
|
||||
"@types/semver": "^7.5.8",
|
||||
@@ -291,7 +291,7 @@
|
||||
"stylelint-scss": "^6.0.0",
|
||||
"stylelint-value-no-unknown-custom-properties": "^6.0.1",
|
||||
"terser-webpack-plugin": "^5.3.9",
|
||||
"testcontainers": "^10.20.0",
|
||||
"testcontainers": "^11.0.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "5.8.3",
|
||||
"util": "^0.12.5",
|
||||
@@ -311,5 +311,6 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/node_modules/@types/react/index.d.ts b/node_modules/@types/react/index.d.ts
|
||||
index 2272032..18bd20a 100644
|
||||
index d3318dc..c2b2c77 100644
|
||||
--- a/node_modules/@types/react/index.d.ts
|
||||
+++ b/node_modules/@types/react/index.d.ts
|
||||
@@ -134,7 +134,7 @@ declare namespace React {
|
||||
@@ -11,7 +11,7 @@ index 2272032..18bd20a 100644
|
||||
|
||||
/**
|
||||
* Created by {@link createRef}, or {@link useRef} when passed `null`.
|
||||
@@ -941,7 +941,7 @@ declare namespace React {
|
||||
@@ -945,7 +945,7 @@ declare namespace React {
|
||||
context: unknown;
|
||||
|
||||
// Keep in sync with constructor signature of JSXElementConstructor and ComponentClass.
|
||||
@@ -20,7 +20,7 @@ index 2272032..18bd20a 100644
|
||||
|
||||
// We MUST keep setState() as a unified signature because it allows proper checking of the method return type.
|
||||
// See: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18365#issuecomment-351013257
|
||||
@@ -1113,7 +1113,7 @@ declare namespace React {
|
||||
@@ -1117,7 +1117,7 @@ declare namespace React {
|
||||
*/
|
||||
interface ComponentClass<P = {}, S = ComponentState> extends StaticLifecycle<P, S> {
|
||||
// constructor signature must match React.Component
|
||||
@@ -23,7 +23,13 @@ test.describe("Encryption state after registration", () => {
|
||||
test("Key backup is enabled by default", async ({ page, mailpitClient, app }, testInfo) => {
|
||||
await page.goto("/#/login");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await registerAccountMas(page, mailpitClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!");
|
||||
await registerAccountMas(
|
||||
page,
|
||||
mailpitClient,
|
||||
`alice_${testInfo.testId}`,
|
||||
`alice_${testInfo.testId}@email.com`,
|
||||
"Pa$sW0rD!",
|
||||
);
|
||||
|
||||
// Wait for the ui to load
|
||||
await expect(page.locator(".mx_MatrixChat")).toBeVisible();
|
||||
@@ -35,7 +41,13 @@ test.describe("Encryption state after registration", () => {
|
||||
test("user is prompted to set up recovery", async ({ page, mailpitClient, app }, testInfo) => {
|
||||
await page.goto("/#/login");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await registerAccountMas(page, mailpitClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!");
|
||||
await registerAccountMas(
|
||||
page,
|
||||
mailpitClient,
|
||||
`alice_${testInfo.testId}`,
|
||||
`alice_${testInfo.testId}@email.com`,
|
||||
"Pa$sW0rD!",
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "Add room" }).click();
|
||||
await page.getByRole("menuitem", { name: "New room" }).click();
|
||||
@@ -64,7 +76,7 @@ test.describe("Key backup reset from elsewhere", () => {
|
||||
|
||||
await page.goto("/#/login");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await registerAccountMas(page, mailpitClient, testUsername, "alice@email.com", testPassword);
|
||||
await registerAccountMas(page, mailpitClient, testUsername, `${testUsername}@email.com`, testPassword);
|
||||
|
||||
await page.getByRole("button", { name: "Add room" }).click();
|
||||
await page.getByRole("menuitem", { name: "New room" }).click();
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "./utils";
|
||||
import { type Bot } from "../../pages/bot";
|
||||
import { Toasts } from "../../pages/toasts.ts";
|
||||
import type { ElementAppPage } from "../../pages/ElementAppPage.ts";
|
||||
|
||||
test.describe("Device verification", { tag: "@no-webkit" }, () => {
|
||||
let aliceBotClient: Bot;
|
||||
@@ -163,39 +164,44 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
|
||||
|
||||
test("Verify device with Security Phrase 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();
|
||||
|
||||
// Fill the passphrase
|
||||
const dialog = page.locator(".mx_Dialog");
|
||||
await dialog.locator("input").fill("new passphrase");
|
||||
await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
|
||||
|
||||
await page.locator(".mx_AuthPage").getByRole("button", { name: "Done" }).click();
|
||||
|
||||
// Check that our device is now cross-signed
|
||||
await checkDeviceIsCrossSigned(app);
|
||||
|
||||
// Check that the current device is connected to key backup
|
||||
// The backup decryption key should be in cache also, as we got it directly from the 4S
|
||||
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
|
||||
await enterRecoveryKeyAndCheckVerified(page, app, "new passphrase");
|
||||
});
|
||||
|
||||
test("Verify device with Recovery Key during login", async ({ page, app, credentials, homeserver }) => {
|
||||
const recoveryKey = (await aliceBotClient.getRecoveryKey()).encodedPrivateKey;
|
||||
|
||||
await logIntoElement(page, credentials);
|
||||
await enterRecoveryKeyAndCheckVerified(page, app, recoveryKey);
|
||||
});
|
||||
|
||||
test("Verify device with Recovery Key from settings", async ({ page, app, credentials }) => {
|
||||
const recoveryKey = (await aliceBotClient.getRecoveryKey()).encodedPrivateKey;
|
||||
|
||||
await logIntoElement(page, credentials);
|
||||
|
||||
// Select the security phrase
|
||||
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key or Phrase" }).click();
|
||||
/* Dismiss "Verify this device" */
|
||||
const authPage = page.locator(".mx_AuthPage");
|
||||
await authPage.getByRole("button", { name: "Skip verification for now" }).click();
|
||||
await authPage.getByRole("button", { name: "I'll verify later" }).click();
|
||||
await page.waitForSelector(".mx_MatrixChat");
|
||||
|
||||
// Fill the recovery key
|
||||
const settings = await app.settings.openUserSettings("Encryption");
|
||||
await settings.getByRole("button", { name: "Verify this device" }).click();
|
||||
await enterRecoveryKeyAndCheckVerified(page, app, recoveryKey);
|
||||
});
|
||||
|
||||
/** Helper for the three tests above which verify by recovery key */
|
||||
async function enterRecoveryKeyAndCheckVerified(page: Page, app: ElementAppPage, recoveryKey: string) {
|
||||
await page.getByRole("button", { name: "Verify with Recovery Key or Phrase" }).click();
|
||||
|
||||
// Enter the recovery key
|
||||
const dialog = page.locator(".mx_Dialog");
|
||||
await dialog.getByRole("button", { name: "use your Recovery Key" }).click();
|
||||
const aliceRecoveryKey = await aliceBotClient.getRecoveryKey();
|
||||
await dialog.locator("#mx_securityKey").fill(aliceRecoveryKey.encodedPrivateKey);
|
||||
await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
|
||||
// We use `pressSequentially` here to make sure that the FocusLock isn't causing us any problems
|
||||
// (cf https://github.com/element-hq/element-web/issues/30089)
|
||||
await dialog.locator("textarea").pressSequentially(recoveryKey);
|
||||
await dialog.getByRole("button", { name: "Continue", disabled: false }).click();
|
||||
|
||||
await page.locator(".mx_AuthPage").getByRole("button", { name: "Done" }).click();
|
||||
await page.getByRole("button", { name: "Done" }).click();
|
||||
|
||||
// Check that our device is now cross-signed
|
||||
await checkDeviceIsCrossSigned(app);
|
||||
@@ -203,7 +209,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
|
||||
// Check that the current device is connected to key backup
|
||||
// The backup decryption key should be in cache also, as we got it directly from the 4S
|
||||
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
|
||||
});
|
||||
}
|
||||
|
||||
test("Handle incoming verification request with SAS", async ({ page, credentials, homeserver, toasts }) => {
|
||||
await logIntoElement(page, credentials);
|
||||
|
||||
@@ -67,8 +67,9 @@ test.describe("Cryptography", function () {
|
||||
// Bob has a second, not cross-signed, device
|
||||
const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob);
|
||||
|
||||
// Dismiss the toast nagging us to set up recovery otherwise it gets in the way of clicking the room list
|
||||
await page.getByRole("button", { name: "Not now" }).click();
|
||||
// Dismiss the toasts nagging us, otherwise they get in the way of clicking the room list
|
||||
await page.getByRole("button", { name: "Dismiss" }).click();
|
||||
await page.getByRole("button", { name: "Yes, dismiss" }).click();
|
||||
|
||||
await bob.sendEvent(testRoomId, null, "m.room.encrypted", {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
import { type GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
|
||||
|
||||
import { test, expect } from "../../element-web-test";
|
||||
import { createBot, deleteCachedSecrets, logIntoElement } from "./utils";
|
||||
import { createBot, deleteCachedSecrets, disableKeyBackup, logIntoElement } from "./utils";
|
||||
import { type Bot } from "../../pages/bot";
|
||||
|
||||
test.describe("Key storage out of sync toast", () => {
|
||||
let recoveryKey: GeneratedSecretStorageKey;
|
||||
@@ -53,3 +54,114 @@ test.describe("Key storage out of sync toast", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("'Turn on key storage' toast", () => {
|
||||
let botClient: Bot | undefined;
|
||||
|
||||
test.beforeEach(async ({ page, homeserver, credentials, toasts }) => {
|
||||
// Set up all crypto stuff. Key storage defaults to on.
|
||||
|
||||
const res = await createBot(page, homeserver, credentials);
|
||||
const recoveryKey = res.recoveryKey;
|
||||
botClient = res.botClient;
|
||||
|
||||
await logIntoElement(page, credentials, recoveryKey.encodedPrivateKey);
|
||||
|
||||
// We won't be prompted for crypto setup unless we have an e2e room, so make one
|
||||
await page.getByRole("button", { name: "Add room" }).click();
|
||||
await page.getByRole("menuitem", { name: "New room" }).click();
|
||||
await page.getByRole("textbox", { name: "Name" }).fill("Test room");
|
||||
await page.getByRole("button", { name: "Create room" }).click();
|
||||
|
||||
await toasts.rejectToast("Notifications");
|
||||
});
|
||||
|
||||
test("should not show toast if key storage is on", async ({ page, toasts }) => {
|
||||
// Given the default situation after signing in
|
||||
// Then no toast is shown (because key storage is on)
|
||||
await toasts.assertNoToasts();
|
||||
|
||||
// When we reload
|
||||
await page.reload();
|
||||
|
||||
// Give the toasts time to appear
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Then still no toast is shown
|
||||
await toasts.assertNoToasts();
|
||||
});
|
||||
|
||||
test("should not show toast if key storage is off because we turned it off", async ({ app, page, toasts }) => {
|
||||
// Given the backup is disabled because we disabled it
|
||||
await disableKeyBackup(app);
|
||||
|
||||
// Then no toast is shown
|
||||
await toasts.assertNoToasts();
|
||||
|
||||
// When we reload
|
||||
await page.reload();
|
||||
|
||||
// Give the toasts time to appear
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Then still no toast is shown
|
||||
await toasts.assertNoToasts();
|
||||
});
|
||||
|
||||
test("should show toast if key storage is off but account data is missing", async ({ app, page, toasts }) => {
|
||||
// Given the backup is disabled but we didn't set account data saying that is expected
|
||||
await disableKeyBackup(app);
|
||||
await botClient.setAccountData("m.org.matrix.custom.backup_disabled", { disabled: false });
|
||||
|
||||
// Wait for the account data setting to stick
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// When we enter the app
|
||||
await page.reload();
|
||||
|
||||
// Then the toast is displayed
|
||||
let toast = await toasts.getToast("Turn on key storage");
|
||||
|
||||
// And when we click "Continue"
|
||||
await toast.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Then we see the Encryption settings dialog with an option to turn on key storage
|
||||
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).toBeVisible();
|
||||
|
||||
// And when we close that
|
||||
await page.getByRole("button", { name: "Close dialog" }).click();
|
||||
|
||||
// Then we see the toast again
|
||||
toast = await toasts.getToast("Turn on key storage");
|
||||
|
||||
// And when we click "Dismiss"
|
||||
await toast.getByRole("button", { name: "Dismiss" }).click();
|
||||
|
||||
// Then we see the "are you sure?" dialog
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Are you sure you want to keep key storage turned off?" }),
|
||||
).toBeVisible();
|
||||
|
||||
// And when we close it by clicking away
|
||||
await page.getByTestId("dialog-background").click({ force: true, position: { x: 10, y: 10 } });
|
||||
|
||||
// Then we see the toast again
|
||||
toast = await toasts.getToast("Turn on key storage");
|
||||
|
||||
// And when we click Dismiss and then "Go to Settings"
|
||||
await toast.getByRole("button", { name: "Dismiss" }).click();
|
||||
await page.getByRole("button", { name: "Go to Settings" }).click();
|
||||
|
||||
// Then we see Encryption settings again
|
||||
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).toBeVisible();
|
||||
|
||||
// And when we close that, see the toast, click Dismiss, and Yes, Dismiss
|
||||
await page.getByRole("button", { name: "Close dialog" }).click();
|
||||
toast = await toasts.getToast("Turn on key storage");
|
||||
await toast.getByRole("button", { name: "Dismiss" }).click();
|
||||
await page.getByRole("button", { name: "Yes, dismiss" }).click();
|
||||
|
||||
// Then the toast is gone
|
||||
await toasts.assertNoToasts();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -228,8 +228,8 @@ export async function logIntoElement(page: Page, credentials: Credentials, secur
|
||||
await useSecurityKey.click();
|
||||
}
|
||||
// Fill in the recovery key
|
||||
await page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey);
|
||||
await page.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
|
||||
await page.locator(".mx_Dialog").locator("textarea").fill(securityKey);
|
||||
await page.getByRole("button", { name: "Continue", disabled: false }).click();
|
||||
await page.getByRole("button", { name: "Done" }).click();
|
||||
}
|
||||
}
|
||||
@@ -263,7 +263,7 @@ 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.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey);
|
||||
await app.page.locator(".mx_Dialog").locator("textarea").fill(securityKey);
|
||||
await app.page.getByRole("button", { name: "Continue", disabled: false }).click();
|
||||
await app.page.getByRole("button", { name: "Done" }).click();
|
||||
await app.settings.closeDialog();
|
||||
@@ -316,6 +316,25 @@ export async function enableKeyBackup(app: ElementAppPage): Promise<string> {
|
||||
return recoveryKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the encryption settings and disable key storage (and recovery)
|
||||
* Assumes that the current device has been verified
|
||||
*/
|
||||
export async function disableKeyBackup(app: ElementAppPage): Promise<void> {
|
||||
const encryptionTab = await app.settings.openUserSettings("Encryption");
|
||||
|
||||
const keyStorageToggle = encryptionTab.getByRole("checkbox", { name: "Allow key storage" });
|
||||
if (await keyStorageToggle.isChecked()) {
|
||||
await encryptionTab.getByRole("checkbox", { name: "Allow key storage" }).click();
|
||||
await encryptionTab.getByRole("button", { name: "Delete key storage" }).click();
|
||||
await encryptionTab.getByRole("checkbox", { name: "Allow key storage" }).isVisible();
|
||||
|
||||
// Wait for the update to account data to stick
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
await app.settings.closeDialog();
|
||||
}
|
||||
|
||||
/**
|
||||
* Go through the "Set up Secure Backup" dialog (aka the `CreateSecretStorageDialog`).
|
||||
*
|
||||
|
||||
@@ -22,11 +22,19 @@ test.describe("Room list filters and sort", () => {
|
||||
});
|
||||
|
||||
function getPrimaryFilters(page: Page): Locator {
|
||||
return page.getByRole("listbox", { name: "Room list filters" });
|
||||
return page.getByTestId("primary-filters");
|
||||
}
|
||||
|
||||
function getSecondaryFilters(page: Page): Locator {
|
||||
return page.getByRole("button", { name: "Filter" });
|
||||
function getRoomOptionsMenu(page: Page): Locator {
|
||||
return page.getByRole("button", { name: "Room Options" });
|
||||
}
|
||||
|
||||
function getFilterExpandButton(page: Page): Locator {
|
||||
return getPrimaryFilters(page).getByRole("button", { name: "Expand filter list" });
|
||||
}
|
||||
|
||||
function getFilterCollapseButton(page: Page): Locator {
|
||||
return getPrimaryFilters(page).getByRole("button", { name: "Collapse filter list" });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,6 +144,7 @@ test.describe("Room list filters and sort", () => {
|
||||
await tile.click();
|
||||
|
||||
// Enable Favourite filter
|
||||
await getFilterExpandButton(page).click();
|
||||
const primaryFilters = getPrimaryFilters(page);
|
||||
await primaryFilters.getByRole("option", { name: "Favourite" }).click();
|
||||
await expect(tile).not.toBeVisible();
|
||||
@@ -177,6 +186,33 @@ test.describe("Room list filters and sort", () => {
|
||||
await app.client.evaluate(async (client, id) => {
|
||||
await client.setRoomTag(id, "m.lowpriority", { order: 0.5 });
|
||||
}, lowPrioId);
|
||||
|
||||
await bot.createRoom({
|
||||
name: "invited room",
|
||||
invite: [user.userId],
|
||||
is_direct: true,
|
||||
});
|
||||
|
||||
const mentionRoomId = await app.client.createRoom({ name: "room with mention" });
|
||||
await app.client.inviteUser(mentionRoomId, bot.credentials.userId);
|
||||
await bot.joinRoom(mentionRoomId);
|
||||
|
||||
const clientBot = await bot.prepareClient();
|
||||
await clientBot.evaluate(
|
||||
async (client, { mentionRoomId, userId }) => {
|
||||
await client.sendMessage(mentionRoomId, {
|
||||
// @ts-ignore ignore usage of MsgType.text
|
||||
"msgtype": "m.text",
|
||||
"body": "User",
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body": `<a href="https://matrix.to/#/${userId}">User</a>`,
|
||||
"m.mentions": {
|
||||
user_ids: [userId],
|
||||
},
|
||||
});
|
||||
},
|
||||
{ mentionRoomId, userId: user.userId },
|
||||
);
|
||||
});
|
||||
|
||||
test("should filter the list (with primary filters)", { tag: "@screenshot" }, async ({ page, app, user }) => {
|
||||
@@ -193,34 +229,38 @@ test.describe("Room list filters and sort", () => {
|
||||
// only one room should be visible
|
||||
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
|
||||
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
|
||||
expect(await roomList.locator("role=gridcell").count()).toBe(2);
|
||||
expect(await roomList.locator("role=gridcell").count()).toBe(4);
|
||||
await expect(primaryFilters).toMatchScreenshot("unread-primary-filters.png");
|
||||
|
||||
await primaryFilters.getByRole("option", { name: "Favourite" }).click();
|
||||
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
|
||||
expect(await roomList.locator("role=gridcell").count()).toBe(1);
|
||||
|
||||
await primaryFilters.getByRole("option", { name: "People" }).click();
|
||||
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
|
||||
expect(await roomList.locator("role=gridcell").count()).toBe(1);
|
||||
await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible();
|
||||
expect(await roomList.locator("role=gridcell").count()).toBe(2);
|
||||
|
||||
await primaryFilters.getByRole("option", { name: "Rooms" }).click();
|
||||
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
|
||||
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
|
||||
await expect(roomList.getByRole("gridcell", { name: "empty room" })).toBeVisible();
|
||||
expect(await roomList.locator("role=gridcell").count()).toBe(4);
|
||||
});
|
||||
|
||||
test("should filter the list (with secondary filters)", { tag: "@screenshot" }, async ({ page, app, user }) => {
|
||||
const roomList = getRoomList(page);
|
||||
const secondaryFilters = getSecondaryFilters(page);
|
||||
await secondaryFilters.click();
|
||||
|
||||
await expect(page.getByRole("menu", { name: "Filter" })).toMatchScreenshot("filter-menu.png");
|
||||
|
||||
await page.getByRole("menuitem", { name: "Low priority" }).click();
|
||||
await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible();
|
||||
await expect(roomList.getByRole("gridcell", { name: "Low prio room" })).toBeVisible();
|
||||
expect(await roomList.locator("role=gridcell").count()).toBe(5);
|
||||
|
||||
await getFilterExpandButton(page).click();
|
||||
|
||||
await primaryFilters.getByRole("option", { name: "Favourite" }).click();
|
||||
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
|
||||
expect(await roomList.locator("role=gridcell").count()).toBe(1);
|
||||
|
||||
await primaryFilters.getByRole("option", { name: "Mentions" }).click();
|
||||
await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible();
|
||||
expect(await roomList.locator("role=gridcell").count()).toBe(1);
|
||||
|
||||
await primaryFilters.getByRole("option", { name: "Invites" }).click();
|
||||
await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible();
|
||||
expect(await roomList.locator("role=gridcell").count()).toBe(1);
|
||||
|
||||
await getFilterCollapseButton(page).click();
|
||||
await expect(primaryFilters.locator("role=option").first()).toHaveText("Invites");
|
||||
});
|
||||
|
||||
test(
|
||||
@@ -252,6 +292,23 @@ test.describe("Room list filters and sort", () => {
|
||||
await expect(roomListView.getByRole("gridcell", { name: "Open room unread dm" })).not.toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
test("should sort the room list alphabetically", async ({ page }) => {
|
||||
const roomListView = getRoomList(page);
|
||||
|
||||
await getRoomOptionsMenu(page).click();
|
||||
await page.getByRole("menuitemradio", { name: "A-Z" }).click();
|
||||
|
||||
await expect(roomListView.getByRole("gridcell").first()).toHaveText(/empty room/);
|
||||
});
|
||||
|
||||
test("should move room to the top on message when sorting by activity", async ({ page, bot }) => {
|
||||
const roomListView = getRoomList(page);
|
||||
|
||||
await bot.sendMessage(unReadDmId, "Hello!");
|
||||
|
||||
await expect(roomListView.getByRole("gridcell").first()).toHaveText(/unread dm/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Empty room list", () => {
|
||||
@@ -269,19 +326,33 @@ test.describe("Room list filters and sort", () => {
|
||||
async ({ page, app, user }) => {
|
||||
const emptyRoomList = getEmptyRoomList(page);
|
||||
await expect(emptyRoomList).toMatchScreenshot("default-empty-room-list.png");
|
||||
await expect(page.getByTestId("room-list-panel")).toMatchScreenshot("room-panel-empty-room-list.png");
|
||||
await expect(page.getByRole("navigation", { name: "Room list" })).toMatchScreenshot(
|
||||
"room-panel-empty-room-list.png",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("should render the placeholder for unread filter", { tag: "@screenshot" }, async ({ page, app, user }) => {
|
||||
const primaryFilters = getPrimaryFilters(page);
|
||||
await primaryFilters.getByRole("option", { name: "Unread" }).click();
|
||||
[
|
||||
{ filter: "Unreads", action: "Show all chats" },
|
||||
{ filter: "Mentions", action: "See all activity" },
|
||||
{ filter: "Invites", action: "See all activity" },
|
||||
].forEach(({ filter, action }) => {
|
||||
test(
|
||||
`should render the placeholder for ${filter} filter`,
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, app, user }) => {
|
||||
const primaryFilters = getPrimaryFilters(page);
|
||||
await getFilterExpandButton(page).click();
|
||||
|
||||
const emptyRoomList = getEmptyRoomList(page);
|
||||
await expect(emptyRoomList).toMatchScreenshot("unread-empty-room-list.png");
|
||||
await primaryFilters.getByRole("option", { name: filter }).click();
|
||||
|
||||
await emptyRoomList.getByRole("button", { name: "show all chats" }).click();
|
||||
await expect(primaryFilters.getByRole("option", { name: "Unread" })).not.toBeChecked();
|
||||
const emptyRoomList = getEmptyRoomList(page);
|
||||
await expect(emptyRoomList).toMatchScreenshot(`${filter}-empty-room-list.png`);
|
||||
|
||||
await emptyRoomList.getByRole("button", { name: action }).click();
|
||||
await expect(primaryFilters.getByRole("option", { name: filter })).not.toBeChecked();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
["People", "Rooms", "Favourite"].forEach((filter) => {
|
||||
@@ -290,6 +361,8 @@ test.describe("Room list filters and sort", () => {
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, app, user }) => {
|
||||
const primaryFilters = getPrimaryFilters(page);
|
||||
await getFilterExpandButton(page).click();
|
||||
|
||||
await primaryFilters.getByRole("option", { name: filter }).click();
|
||||
|
||||
const emptyRoomList = getEmptyRoomList(page);
|
||||
|
||||
@@ -19,7 +19,7 @@ test.describe("Room list panel", () => {
|
||||
* @param page
|
||||
*/
|
||||
function getRoomListView(page: Page) {
|
||||
return page.getByTestId("room-list-panel");
|
||||
return page.getByRole("navigation", { name: "Room list" });
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, app, user }) => {
|
||||
@@ -30,6 +30,9 @@ test.describe("Room list panel", () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await app.client.createRoom({ name: `room${i}` });
|
||||
}
|
||||
|
||||
// focus the user menu to avoid to have hover decoration
|
||||
await page.getByRole("button", { name: "User menu" }).focus();
|
||||
});
|
||||
|
||||
test("should render the room list panel", { tag: "@screenshot" }, async ({ page, app, user }) => {
|
||||
@@ -38,4 +41,10 @@ test.describe("Room list panel", () => {
|
||||
await expect(roomListView.getByRole("gridcell", { name: "Open room room19" })).toBeVisible();
|
||||
await expect(roomListView).toMatchScreenshot("room-list-panel.png");
|
||||
});
|
||||
|
||||
test("should respond to small screen sizes", { tag: "@screenshot" }, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 575, height: 600 });
|
||||
const roomListPanel = getRoomListView(page);
|
||||
await expect(roomListPanel).toMatchScreenshot("room-list-panel-smallscreen.png");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,9 @@ test.describe("Room list", () => {
|
||||
test.beforeEach(async ({ page, app, user }) => {
|
||||
// The notification toast is displayed above the search section
|
||||
await app.closeNotificationToast();
|
||||
|
||||
// focus the user menu to avoid to have hover decoration
|
||||
await page.getByRole("button", { name: "User menu" }).focus();
|
||||
});
|
||||
|
||||
test.describe("Room list", () => {
|
||||
@@ -241,6 +244,10 @@ test.describe("Room list", () => {
|
||||
test("should be a public room", { tag: "@screenshot" }, async ({ page, app, user }) => {
|
||||
// @ts-ignore Visibility enum is not accessible
|
||||
await app.client.createRoom({ name: "public room", visibility: "public" });
|
||||
|
||||
// focus the user menu to avoid to have hover decoration
|
||||
await page.getByRole("button", { name: "User menu" }).focus();
|
||||
|
||||
const roomListView = getRoomList(page);
|
||||
const publicRoom = roomListView.getByRole("gridcell", { name: "public room" });
|
||||
|
||||
@@ -248,14 +255,40 @@ test.describe("Room list", () => {
|
||||
await expect(publicRoom).toMatchScreenshot("room-list-item-public.png");
|
||||
});
|
||||
|
||||
test("should be a low priority room", { tag: "@screenshot" }, async ({ page, app, user }) => {
|
||||
// @ts-ignore Visibility enum is not accessible
|
||||
await app.client.createRoom({ name: "low priority room", visibility: "public" });
|
||||
const roomListView = getRoomList(page);
|
||||
const publicRoom = roomListView.getByRole("gridcell", { name: "low priority room" });
|
||||
|
||||
// Make room low priority
|
||||
await publicRoom.hover();
|
||||
const roomItemMenu = publicRoom.getByRole("button", { name: "More Options" });
|
||||
await roomItemMenu.click();
|
||||
await page.getByRole("menuitemcheckbox", { name: "Low priority" }).click();
|
||||
|
||||
// Should have low priority decoration
|
||||
await expect(publicRoom.locator(".mx_RoomAvatarView_icon")).toHaveAccessibleName(
|
||||
"This is a low priority room",
|
||||
);
|
||||
|
||||
// focus the user menu to avoid to have hover decoration
|
||||
await page.getByRole("button", { name: "User menu" }).focus();
|
||||
await expect(publicRoom).toMatchScreenshot("room-list-item-low-priority.png");
|
||||
});
|
||||
|
||||
test("should be a video room", { tag: "@screenshot" }, async ({ page, app, user }) => {
|
||||
await page.getByTestId("room-list-panel").getByRole("button", { name: "Add" }).click();
|
||||
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
|
||||
await page.getByRole("menuitem", { name: "New video room" }).click();
|
||||
await page.getByRole("textbox", { name: "Name" }).fill("video room");
|
||||
await page.getByRole("button", { name: "Create video room" }).click();
|
||||
|
||||
const roomListView = getRoomList(page);
|
||||
const videoRoom = roomListView.getByRole("gridcell", { name: "video room" });
|
||||
|
||||
// focus the user menu to avoid to have hover decoration
|
||||
await page.getByRole("button", { name: "User menu" }).focus();
|
||||
|
||||
await expect(videoRoom).toBeVisible();
|
||||
await expect(videoRoom).toMatchScreenshot("room-list-item-video.png");
|
||||
});
|
||||
@@ -322,12 +355,17 @@ test.describe("Room list", () => {
|
||||
});
|
||||
|
||||
test("should render a message preview", { tag: "@screenshot" }, async ({ page, app, user, bot }) => {
|
||||
await app.settings.openUserSettings("Preferences");
|
||||
await page.getByRole("switch", { name: "Show message previews" }).click();
|
||||
await app.closeDialog();
|
||||
|
||||
const roomListView = getRoomList(page);
|
||||
|
||||
await page.getByRole("button", { name: "Room Options" }).click();
|
||||
await page.getByRole("menuitemcheckbox", { name: "Show message previews" }).click();
|
||||
|
||||
const roomId = await app.client.createRoom({ name: "activity" });
|
||||
|
||||
// focus the user menu to avoid to have hover decoration
|
||||
await page.getByRole("button", { name: "User menu" }).focus();
|
||||
|
||||
await app.client.inviteUser(roomId, bot.credentials.userId);
|
||||
await bot.joinRoom(roomId);
|
||||
await bot.sendMessage(roomId, "I am a robot. Beep.");
|
||||
@@ -376,8 +414,8 @@ test.describe("Room list", () => {
|
||||
await room.getByRole("button", { name: "More Options" }).click();
|
||||
await page.getByRole("menuitem", { name: "mark as unread" }).click();
|
||||
|
||||
// Remove hover on the room list item
|
||||
await roomListView.hover();
|
||||
// focus the user menu to avoid to have hover decoration
|
||||
await page.getByRole("button", { name: "User menu" }).focus();
|
||||
|
||||
await expect(room).toMatchScreenshot("room-list-item-mark-as-unread.png");
|
||||
});
|
||||
|
||||
112
playwright/e2e/modules/custom-component.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
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";
|
||||
|
||||
const screenshotOptions = (page: Page) => ({
|
||||
mask: [page.locator(".mx_MessageTimestamp")],
|
||||
// Hide the jump to bottom button in the timeline to avoid flakiness
|
||||
// Exclude timestamp and read marker from snapshot
|
||||
css: `
|
||||
.mx_JumpToBottomButton {
|
||||
display: none !important;
|
||||
}
|
||||
.mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
});
|
||||
test.describe("Custom Component API", () => {
|
||||
test.use({
|
||||
displayName: "Manny",
|
||||
config: {
|
||||
modules: ["/modules/custom-component-module.js"],
|
||||
},
|
||||
page: async ({ page }, use) => {
|
||||
await page.route("/modules/custom-component-module.js", async (route) => {
|
||||
await route.fulfill({ path: "playwright/sample-files/custom-component-module.js" });
|
||||
});
|
||||
await use(page);
|
||||
},
|
||||
room: async ({ page, app, user, bot }, use) => {
|
||||
const roomId = await app.client.createRoom({ name: "TestRoom" });
|
||||
await use({ roomId });
|
||||
},
|
||||
});
|
||||
test.describe("basic functionality", () => {
|
||||
test(
|
||||
"should replace the render method of a textual event",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
await app.client.sendMessage(room.roomId, "Simple message");
|
||||
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
|
||||
"custom-component-tile.png",
|
||||
screenshotOptions(page),
|
||||
);
|
||||
},
|
||||
);
|
||||
test(
|
||||
"should fall through if one module does not render a component",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
await app.client.sendMessage(room.roomId, "Fall through here");
|
||||
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
|
||||
"custom-component-tile-fall-through.png",
|
||||
screenshotOptions(page),
|
||||
);
|
||||
},
|
||||
);
|
||||
test(
|
||||
"should render the original content of a textual event conditionally",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
await app.client.sendMessage(room.roomId, "Do not replace me");
|
||||
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
|
||||
"custom-component-tile-original.png",
|
||||
screenshotOptions(page),
|
||||
);
|
||||
},
|
||||
);
|
||||
test("should disallow editing when the allowEditingEvent hint is set to false", async ({ page, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
await app.client.sendMessage(room.roomId, "Do not show edits");
|
||||
await page.getByText("Do not show edits").hover();
|
||||
await expect(
|
||||
await page.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "Edit" }),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
test(
|
||||
"should render the next registered component if the filter function throws",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
await app.client.sendMessage(room.roomId, "Crash the filter!");
|
||||
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
|
||||
"custom-component-crash-handle-filter.png",
|
||||
screenshotOptions(page),
|
||||
);
|
||||
},
|
||||
);
|
||||
test(
|
||||
"should render original component if the render function throws",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
await app.client.sendMessage(room.roomId, "Crash the renderer!");
|
||||
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
|
||||
"custom-component-crash-handle-renderer.png",
|
||||
screenshotOptions(page),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,9 @@ import { type Page } from "@playwright/test";
|
||||
|
||||
import { expect } from "../../element-web-test";
|
||||
|
||||
/**
|
||||
* Click through registering a new user in the MAS UI.
|
||||
*/
|
||||
export async function registerAccountMas(
|
||||
page: Page,
|
||||
mailpit: MailpitClient,
|
||||
@@ -37,6 +40,22 @@ export async function registerAccountMas(
|
||||
|
||||
await page.getByRole("textbox", { name: "6-digit code" }).fill(code);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByRole("textbox", { name: "Display Name" }).fill(username);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await expect(page.getByText("Allow access to your account?")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Click through entering username and password into the MAS login prompt.
|
||||
*/
|
||||
export async function logInAccountMas(page: Page, username: string, password: string): Promise<void> {
|
||||
await expect(page.getByText("Please sign in to continue:")).toBeVisible();
|
||||
|
||||
await page.getByRole("textbox", { name: "Username" }).fill(username);
|
||||
await page.getByRole("textbox", { name: "Password", exact: true }).fill(password);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await expect(page.getByText("Allow access to your account?")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
}
|
||||
|
||||
@@ -6,8 +6,12 @@ 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 Config, CONFIG_JSON } from "@element-hq/element-web-playwright-common";
|
||||
import { type Browser, type Page } from "@playwright/test";
|
||||
import { type StartedHomeserverContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers/HomeserverContainer";
|
||||
|
||||
import { test, expect } from "../../element-web-test.ts";
|
||||
import { registerAccountMas } from ".";
|
||||
import { logInAccountMas, registerAccountMas } from ".";
|
||||
import { ElementAppPage } from "../../pages/ElementAppPage.ts";
|
||||
import { masHomeserver } from "../../plugins/homeserver/synapse/masHomeserver.ts";
|
||||
|
||||
@@ -33,7 +37,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
const userId = `alice_${testInfo.testId}`;
|
||||
await registerAccountMas(page, mailpitClient, userId, "alice@email.com", "Pa$sW0rD!");
|
||||
await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, "Pa$sW0rD!");
|
||||
|
||||
// Eventually, we should end up at the home screen.
|
||||
await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 });
|
||||
@@ -55,7 +59,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
const newPage = await newPagePromise;
|
||||
await newPage.getByText("Devices").click();
|
||||
await newPage.getByText(deviceId).click();
|
||||
await expect(newPage.getByText("Element")).toBeVisible();
|
||||
await expect(newPage.getByText("Element", { exact: true })).toBeVisible();
|
||||
await expect(newPage.getByText("http://localhost:8080/")).toBeVisible();
|
||||
await expect(newPage).toHaveURL(/\/oauth2_session/);
|
||||
await newPage.close();
|
||||
@@ -83,7 +87,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
const userId = `alice_${testInfo.testId}`;
|
||||
await registerAccountMas(page, mailpitClient, userId, "alice@email.com", "Pa$sW0rD!");
|
||||
await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, "Pa$sW0rD!");
|
||||
|
||||
await expect(page.getByText("Welcome")).toBeVisible();
|
||||
await page.goto("about:blank");
|
||||
@@ -101,4 +105,154 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
expect(localStorageKeys).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
test("can log in to an existing MAS account", { tag: "@screenshot" }, async ({ page, mailpitClient }, testInfo) => {
|
||||
// Register an account with MAS
|
||||
await page.goto("/#/login");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
const userId = `alice_${testInfo.testId}`;
|
||||
await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, "Pa$sW0rD!");
|
||||
await expect(page.getByText("Welcome")).toBeVisible();
|
||||
|
||||
// Log out
|
||||
await page.getByRole("button", { name: "User menu" }).click();
|
||||
await expect(page.getByText(userId, { exact: true })).toBeVisible();
|
||||
|
||||
// Allow the outstanding requests queue to settle before logging out
|
||||
await page.waitForTimeout(2000);
|
||||
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click();
|
||||
await expect(page).toHaveURL(/\/#\/login$/);
|
||||
|
||||
// Log in again
|
||||
await page.goto("/#/login");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// We should be in (we see an error because we have no recovery key).
|
||||
await expect(page.getByText("Unable to verify this device")).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe("with force_verification on", () => {
|
||||
test.use({
|
||||
config: {
|
||||
force_verification: true,
|
||||
},
|
||||
});
|
||||
|
||||
test("verify dialog cannot be dismissed", { tag: "@screenshot" }, async ({ page, mailpitClient }, testInfo) => {
|
||||
// Register an account with MAS
|
||||
await page.goto("/#/login");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
const userId = `alice_${testInfo.testId}`;
|
||||
await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, "Pa$sW0rD!");
|
||||
await expect(page.getByText("Welcome")).toBeVisible();
|
||||
|
||||
// Log out
|
||||
await page.getByRole("button", { name: "User menu" }).click();
|
||||
await expect(page.getByText(userId, { exact: true })).toBeVisible();
|
||||
await page.waitForTimeout(2000);
|
||||
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click();
|
||||
await expect(page).toHaveURL(/\/#\/login$/);
|
||||
|
||||
// Log in again
|
||||
await page.goto("/#/login");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// We should be being warned that we need to verify (but we can't)
|
||||
await expect(page.getByText("Unable to verify this device")).toBeVisible();
|
||||
|
||||
// And there should be no way to close this prompt
|
||||
await expect(page.getByRole("button", { name: "Skip verification for now" })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test(
|
||||
"continues to show verification prompt after cancelling device verification",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ browser, config, homeserver, page, mailpitClient }, testInfo) => {
|
||||
// Register an account with MAS
|
||||
await page.goto("/#/login");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
const userId = `alice_${testInfo.testId}`;
|
||||
const password = "Pa$sW0rD!";
|
||||
await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, password);
|
||||
await expect(page.getByText("Welcome")).toBeVisible();
|
||||
|
||||
// Log in an additional account, and verify it.
|
||||
//
|
||||
// This means that when we log out and in again, we are offered
|
||||
// to verify using another device.
|
||||
const otherContext = await newContext(browser, config, homeserver);
|
||||
const otherDevicePage = await otherContext.newPage();
|
||||
await otherDevicePage.goto("/#/login");
|
||||
await otherDevicePage.getByRole("button", { name: "Continue" }).click();
|
||||
await logInAccountMas(otherDevicePage, userId, password);
|
||||
await verifyUsingOtherDevice(otherDevicePage, page);
|
||||
await otherDevicePage.close();
|
||||
|
||||
// Log out
|
||||
await page.getByRole("button", { name: "User menu" }).click();
|
||||
await expect(page.getByText(userId, { exact: true })).toBeVisible();
|
||||
await page.waitForTimeout(2000);
|
||||
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click();
|
||||
await expect(page).toHaveURL(/\/#\/login$/);
|
||||
|
||||
// Log in again
|
||||
await page.goto("/#/login");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// We should be in, and not able to dismiss the verify dialog
|
||||
await expect(page.getByText("Verify this device")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Skip verification for now" })).not.toBeVisible();
|
||||
|
||||
// When we start verifying with another device
|
||||
await page.getByRole("button", { name: "Verify with another device" }).click();
|
||||
|
||||
// And then cancel it
|
||||
await page.getByRole("button", { name: "Close dialog" }).click();
|
||||
|
||||
// Then we should still be at the unskippable verify prompt
|
||||
await expect(page.getByText("Verify this device")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Skip verification for now" })).not.toBeVisible();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Perform interactive emoji verification for a new device.
|
||||
*/
|
||||
async function verifyUsingOtherDevice(deviceToVerifyPage: Page, alreadyVerifiedDevicePage: Page) {
|
||||
await deviceToVerifyPage.getByRole("button", { name: "Verify with another device" }).click();
|
||||
await alreadyVerifiedDevicePage.getByRole("button", { name: "Verify session" }).click();
|
||||
await alreadyVerifiedDevicePage.getByRole("button", { name: "Start" }).click();
|
||||
await alreadyVerifiedDevicePage.getByRole("button", { name: "They match" }).click();
|
||||
await deviceToVerifyPage.getByRole("button", { name: "They match" }).click();
|
||||
await alreadyVerifiedDevicePage.getByRole("button", { name: "Got it" }).click();
|
||||
await deviceToVerifyPage.getByRole("button", { name: "Got it" }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new browser context which serves up the default config plus what you supplied, and sets m.homeserver to the
|
||||
* supplied homeserver's URL.
|
||||
*/
|
||||
async function newContext(browser: Browser, config: Partial<Partial<Config>>, homeserver: StartedHomeserverContainer) {
|
||||
const otherContext = await browser.newContext();
|
||||
await otherContext.route(`http://localhost:8080/config.json*`, async (route) => {
|
||||
const json = {
|
||||
...CONFIG_JSON,
|
||||
...config,
|
||||
default_server_config: {
|
||||
"m.homeserver": {
|
||||
base_url: homeserver.baseUrl,
|
||||
},
|
||||
},
|
||||
};
|
||||
await route.fulfill({ json });
|
||||
});
|
||||
return otherContext;
|
||||
}
|
||||
|
||||
@@ -81,10 +81,12 @@ test.describe("RightPanel", () => {
|
||||
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("with-leave-room.png");
|
||||
});
|
||||
|
||||
test("should handle clicking add widgets", async ({ page, app }) => {
|
||||
test("should handle clicking add widgets", { tag: "@screenshot" }, async ({ page, app }) => {
|
||||
await viewRoomSummaryByName(page, app, ROOM_NAME);
|
||||
|
||||
await page.getByRole("menuitem", { name: "Extensions" }).click();
|
||||
await expect(page.getByTestId("right-panel")).toMatchScreenshot("with-extensions.png");
|
||||
|
||||
await page.getByRole("button", { name: "Add extensions" }).click();
|
||||
await expect(page.locator(".mx_IntegrationManager")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -21,10 +21,12 @@ test.describe("Preferences user settings tab", () => {
|
||||
const locator = await app.settings.openUserSettings("Preferences");
|
||||
await use(locator);
|
||||
},
|
||||
// display message preview settings
|
||||
labsFlags: ["feature_new_room_list"],
|
||||
});
|
||||
|
||||
test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page, user }) => {
|
||||
await page.setViewportSize({ width: 1024, height: 3300 });
|
||||
await page.setViewportSize({ width: 1024, height: 4000 });
|
||||
const tab = await app.settings.openUserSettings("Preferences");
|
||||
// Assert that the top heading is rendered
|
||||
await expect(tab.getByRole("heading", { name: "Preferences" })).toBeVisible();
|
||||
|
||||
34
playwright/e2e/share-dialog/share-by-url.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../../element-web-test";
|
||||
|
||||
test.describe("share from URL", () => {
|
||||
test.use({
|
||||
displayName: "Alice",
|
||||
room: async ({ app }, use) => {
|
||||
const roomId = await app.client.createRoom({ name: "A test room" });
|
||||
await use({ roomId });
|
||||
},
|
||||
});
|
||||
|
||||
test("should share message when users navigates to share URL", async ({ page, user, room, app }) => {
|
||||
await page.goto("/#/share?msg=Hello+world");
|
||||
// The forward message dialog doesn't update as new infomation arrives via sync, which means sometimes
|
||||
// this is just says, "Empty room". For the same reason, we can't reliably write a test for loading the
|
||||
// app straight away with a /#/share url as the room doesn't appear until the client syncs.]
|
||||
// Ideally we should fix the forward dialog to update and eliminate races, until then, there is only one
|
||||
// room so we click the first button.
|
||||
await page.getByRole("listitem" /*, { name: "A test room" }*/).getByRole("button", { name: "Send" }).click();
|
||||
await page.keyboard.press("Escape");
|
||||
await app.viewRoomByName("A test room");
|
||||
const lastMessage = page.locator(".mx_RoomView_MessageList .mx_EventTile_last");
|
||||
await expect(lastMessage).toBeVisible();
|
||||
const lastMessageText = await lastMessage.locator(".mx_EventTile_body").innerText();
|
||||
await expect(lastMessageText).toBe("Hello world");
|
||||
});
|
||||
});
|
||||
55
playwright/sample-files/custom-component-module.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export default class CustomComponentModule {
|
||||
static moduleApiVersion = "^1.2.0";
|
||||
constructor(api) {
|
||||
this.api = api;
|
||||
this.api.customComponents.registerMessageRenderer(
|
||||
(evt) => evt.content.body === "Do not show edits",
|
||||
(_props, originalComponent) => {
|
||||
return originalComponent();
|
||||
},
|
||||
{ allowEditingEvent: false },
|
||||
);
|
||||
this.api.customComponents.registerMessageRenderer(
|
||||
(evt) => evt.content.body === "Fall through here",
|
||||
(props) => {
|
||||
const body = props.mxEvent.content.body;
|
||||
return `Fallthrough text for ${body}`;
|
||||
},
|
||||
);
|
||||
this.api.customComponents.registerMessageRenderer(
|
||||
(evt) => {
|
||||
if (evt.content.body === "Crash the filter!") {
|
||||
throw new Error("Fail test!");
|
||||
}
|
||||
return false;
|
||||
},
|
||||
() => {
|
||||
return `Should not render!`;
|
||||
},
|
||||
);
|
||||
this.api.customComponents.registerMessageRenderer(
|
||||
(evt) => evt.content.body === "Crash the renderer!",
|
||||
() => {
|
||||
throw new Error("Fail test!");
|
||||
},
|
||||
);
|
||||
// Order is specific here to avoid this overriding the other renderers
|
||||
this.api.customComponents.registerMessageRenderer("m.room.message", (props, originalComponent) => {
|
||||
const body = props.mxEvent.content.body;
|
||||
if (body === "Do not replace me") {
|
||||
return originalComponent();
|
||||
} else if (body === "Fall through here") {
|
||||
return null;
|
||||
}
|
||||
return `Custom text for ${body}`;
|
||||
});
|
||||
}
|
||||
async load() {}
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 272 KiB |
@@ -5,8 +5,7 @@
|
||||
"appIDs": [
|
||||
"7J4U792NQT.im.vector.app",
|
||||
"7J4U792NQT.io.element.elementx",
|
||||
"7J4U792NQT.io.element.elementx.nightly",
|
||||
"7J4U792NQT.io.element.elementx.pr"
|
||||
"7J4U792NQT.io.element.elementx.nightly"
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
@@ -28,8 +27,7 @@
|
||||
"apps": [
|
||||
"7J4U792NQT.im.vector.app",
|
||||
"7J4U792NQT.io.element.elementx",
|
||||
"7J4U792NQT.io.element.elementx.nightly",
|
||||
"7J4U792NQT.io.element.elementx.pr"
|
||||
"7J4U792NQT.io.element.elementx.nightly"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,6 +593,7 @@ legend {
|
||||
.mx_Dialog
|
||||
button:not(
|
||||
.mx_EncryptionUserSettingsTab button,
|
||||
.mx_EncryptionCard button,
|
||||
.mx_UserProfileSettings button,
|
||||
.mx_ShareDialog button,
|
||||
.mx_UnpinAllDialog button,
|
||||
@@ -600,6 +601,7 @@ legend {
|
||||
.mx_Dialog_nonDialogButton,
|
||||
.mx_AccessibleButton,
|
||||
.mx_IdentityServerPicker button,
|
||||
.mx_AccessSecretStorageDialog button,
|
||||
[class|="maplibregl"]
|
||||
),
|
||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton),
|
||||
|
||||
@@ -131,6 +131,7 @@
|
||||
@import "./views/dialogs/_BugReportDialog.pcss";
|
||||
@import "./views/dialogs/_ChangelogDialog.pcss";
|
||||
@import "./views/dialogs/_CompoundDialog.pcss";
|
||||
@import "./views/dialogs/_ConfirmKeyStorageOffDialog.pcss";
|
||||
@import "./views/dialogs/_ConfirmSpaceUserActionDialog.pcss";
|
||||
@import "./views/dialogs/_ConfirmUserActionDialog.pcss";
|
||||
@import "./views/dialogs/_CreateRoomDialog.pcss";
|
||||
|
||||
@@ -28,6 +28,12 @@ Please see LICENSE files in the repository root for full details.
|
||||
--collapsedWidth: 68px;
|
||||
}
|
||||
|
||||
.mx_LeftPanel_newRoomList {
|
||||
/* Thew new rooms list is not designed to be collapsed to just icons. */
|
||||
/* 224 + 68(spaces bar) was deemed by design to be a good minimum for the left panel. */
|
||||
--collapsedWidth: 224px;
|
||||
}
|
||||
|
||||
.mx_LeftPanel_wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -30,6 +30,11 @@ Please see LICENSE files in the repository root for full details.
|
||||
width: 68px;
|
||||
}
|
||||
|
||||
&.newUi {
|
||||
background-color: var(--cpd-color-bg-canvas-default);
|
||||
border-right: 1px solid var(--cpd-color-bg-subtle-primary);
|
||||
}
|
||||
|
||||
.mx_SpacePanel_toggleCollapse {
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
@@ -399,6 +404,11 @@ Please see LICENSE files in the repository root for full details.
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&.newUi .mx_UserMenu {
|
||||
margin-top: var(--cpd-space-4x);
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_SpacePanel_contextMenu {
|
||||
|
||||
@@ -49,12 +49,12 @@
|
||||
&:hover,
|
||||
&:hover .mx_ThreadsActivityCentreButton_Icon {
|
||||
background-color: $quaternary-content;
|
||||
color: $primary-content;
|
||||
fill: $primary-content;
|
||||
}
|
||||
}
|
||||
|
||||
& .mx_ThreadsActivityCentreButton_Icon {
|
||||
color: $secondary-content;
|
||||
fill: $secondary-content;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,11 @@ Please see LICENSE files in the repository root for full details.
|
||||
background-color: $primary-content;
|
||||
}
|
||||
|
||||
&.mx_Toast_icon_key_storage::after {
|
||||
mask-image: url("@vector-im/compound-design-tokens/icons/settings-solid.svg");
|
||||
background-color: $primary-content;
|
||||
}
|
||||
|
||||
&.mx_Toast_icon_labs::after {
|
||||
mask-image: url("$(res)/img/element-icons/flask.svg");
|
||||
background-color: $secondary-content;
|
||||
|
||||
16
res/css/views/dialogs/_ConfirmKeyStorageOffDialog.pcss
Normal file
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.mx_ConfirmKeyStorageOffDialog {
|
||||
.mx_Dialog_border {
|
||||
width: 600px;
|
||||
}
|
||||
|
||||
.mx_EncryptionCard {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -7,62 +7,14 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.mx_AccessSecretStorageDialog {
|
||||
.mx_AccessSecretStorageDialog_titleWithIcon {
|
||||
&::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-inline-end: $spacing-8;
|
||||
position: relative;
|
||||
top: 5px;
|
||||
background-color: $primary-content;
|
||||
}
|
||||
|
||||
&.mx_AccessSecretStorageDialog_resetBadge::before {
|
||||
/* The image isn't capable of masking, so we use a background instead. */
|
||||
background-image: url("@vector-im/compound-design-tokens/icons/error-solid.svg");
|
||||
background-size: 24px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&.mx_AccessSecretStorageDialog_secureBackupTitle::before {
|
||||
mask-image: url("$(res)/img/feather-customised/secure-backup.svg");
|
||||
}
|
||||
|
||||
&.mx_AccessSecretStorageDialog_securePhraseTitle::before {
|
||||
mask-image: url("$(res)/img/feather-customised/secure-phrase.svg");
|
||||
}
|
||||
&.mx_EncryptionCard {
|
||||
/* override some styles that we don't need */
|
||||
border: 0px none;
|
||||
box-shadow: none;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.mx_AccessSecretStorageDialog_primaryContainer {
|
||||
.mx_AccessSecretStorageDialog_passPhraseInput {
|
||||
width: 300px;
|
||||
border: 1px solid $accent;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.mx_AccessSecretStorageDialog_keyStatus {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.mx_AccessSecretStorageDialog_recoveryKeyEntry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.mx_AccessSecretStorageDialog_recoveryKeyEntry_textInput {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.mx_AccessSecretStorageDialog_recoveryKeyEntry_entryControlSeparatorText {
|
||||
margin: $spacing-16;
|
||||
}
|
||||
|
||||
.mx_AccessSecretStorageDialog_recoveryKeyEntry_fileInput {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_AccessSecretStorageDialog_recoveryKeyFeedback {
|
||||
&::before {
|
||||
content: "";
|
||||
@@ -76,64 +28,18 @@ Please see LICENSE files in the repository root for full details.
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
|
||||
&.mx_AccessSecretStorageDialog_recoveryKeyFeedback--valid {
|
||||
color: $accent;
|
||||
|
||||
&::before {
|
||||
mask-image: url("@vector-im/compound-design-tokens/icons/check.svg");
|
||||
background-color: $accent;
|
||||
}
|
||||
}
|
||||
|
||||
&.mx_AccessSecretStorageDialog_recoveryKeyFeedback--invalid {
|
||||
color: $alert;
|
||||
|
||||
&::before {
|
||||
mask-image: url("@vector-im/compound-design-tokens/icons/close.svg");
|
||||
mask-image: url("@vector-im/compound-design-tokens/icons/error-solid.svg");
|
||||
background-color: $alert;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_Dialog_buttons {
|
||||
$spacingStart: $spacing-24; /* 16px icon + 8px padding */
|
||||
|
||||
text-align: initial;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 14px;
|
||||
|
||||
.mx_Dialog_buttons_additive {
|
||||
float: none;
|
||||
|
||||
.mx_AccessSecretStorageDialog_reset {
|
||||
position: relative;
|
||||
padding-inline-start: $spacingStart;
|
||||
/* To avoid bold styling inherent with <strong> elements */
|
||||
font-weight: inherit;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 0;
|
||||
top: 2px; /* alignment */
|
||||
background-image: url("@vector-im/compound-design-tokens/icons/error-solid.svg");
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.mx_AccessSecretStorageDialog_reset_link {
|
||||
color: $alert;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_Dialog_buttons_row {
|
||||
gap: $spacing-16; /* TODO: needs normalization */
|
||||
padding-inline-start: $spacingStart;
|
||||
}
|
||||
}
|
||||
.mx_EncryptionCard_buttons {
|
||||
margin-top: var(--cpd-space-20x);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,11 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.mx_ExtensionsCard {
|
||||
--cpd-separator-inset: var(--cpd-space-4x);
|
||||
--cpd-separator-spacing: var(--cpd-space-4x);
|
||||
|
||||
--cpd-separator-spacing: var(--cpd-space-6x);
|
||||
--AddExtension-overlap: -76px;
|
||||
.mx_AutoHideScrollbar {
|
||||
padding: 0 var(--cpd-space-4x);
|
||||
margin-top: var(--cpd-space-3x);
|
||||
margin-top: var(--cpd-space-6x);
|
||||
box-sizing: border-box;
|
||||
|
||||
/* Styling for the "Add extensions" button */
|
||||
@@ -128,6 +127,11 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
.mx_EmptyState::before {
|
||||
/* Overlap the Add extensions button */
|
||||
top: -76px;
|
||||
top: var(--AddExtension-overlap);
|
||||
}
|
||||
|
||||
.mx_EmptyState {
|
||||
/* Stop empty state scrolling */
|
||||
height: calc(100% + var(--AddExtension-overlap));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,19 +7,19 @@
|
||||
|
||||
/**
|
||||
* The RoomListItemView has the following structure:
|
||||
* button----------------------------------------|
|
||||
* | <-12px-> container--------------------------|
|
||||
* | | room avatar <-12px-> content-----|
|
||||
* | | | room_name |
|
||||
* | | | ----------| <-- border
|
||||
* |---------------------------------------------|
|
||||
* button--------------------------------------------------|
|
||||
* | <-12px-> container------------------------------------|
|
||||
* | | room avatar <-8px-> content----------------|
|
||||
* | | | room_name <- 20px ->|
|
||||
* | | | --------------------| <-- border
|
||||
* |-------------------------------------------------------|
|
||||
*/
|
||||
.mx_RoomListItemView {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
|
||||
.mx_RoomListItemView_container {
|
||||
padding-left: var(--cpd-space-2x);
|
||||
padding-left: var(--cpd-space-3x);
|
||||
font: var(--cpd-font-body-md-regular);
|
||||
height: 100%;
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
border-bottom: var(--cpd-border-width-0-5) solid var(--cpd-color-bg-subtle-secondary);
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
padding-right: var(--cpd-space-5x);
|
||||
|
||||
.mx_RoomListItemView_text {
|
||||
min-width: 0;
|
||||
@@ -56,26 +57,20 @@
|
||||
background-color: var(--cpd-color-bg-action-secondary-hovered);
|
||||
}
|
||||
|
||||
.mx_RoomListItemView_menu_open .mx_RoomListItemView_content {
|
||||
padding-right: var(--cpd-space-1-5x);
|
||||
.mx_RoomListItemView_menu_open .mx_RoomListItemView_container .mx_RoomListItemView_content {
|
||||
/**
|
||||
* The figma uses 16px padding (--cpd-space-4x) but due to https://github.com/element-hq/compound-web/issues/331
|
||||
* the icon size of the menu is 18px instead of 20px with a different internal padding
|
||||
* We need to use 18px to align the icon with the others icons
|
||||
* 18px is not available in compound spacing
|
||||
*/
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.mx_RoomListItemView_selected {
|
||||
background-color: var(--cpd-color-bg-action-secondary-pressed);
|
||||
}
|
||||
|
||||
.mx_RoomListItemView_notification_decoration {
|
||||
.mx_RoomListItemView_content {
|
||||
padding-right: var(--cpd-space-2x);
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RoomListItemView_empty {
|
||||
.mx_RoomListItemView_content {
|
||||
padding-right: var(--cpd-space-3x);
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RoomListItemView_bold .mx_RoomListItemView_roomName {
|
||||
font: var(--cpd-font-body-md-semibold);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,32 @@
|
||||
*/
|
||||
|
||||
.mx_RoomListPrimaryFilters {
|
||||
margin: unset;
|
||||
list-style-type: none;
|
||||
padding: var(--cpd-space-2x) var(--cpd-space-3x);
|
||||
padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-3x);
|
||||
|
||||
.mx_RoomListPrimaryFilters_wrapping {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: unset;
|
||||
padding: unset;
|
||||
list-style-type: none;
|
||||
/**
|
||||
* The InteractionObserver needs the height to be set to work properly.
|
||||
*/
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mx_RoomListPrimaryFilters_IconButton {
|
||||
svg {
|
||||
transition: transform 0.1s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RoomListPrimaryFilters_IconButton[aria-expanded="true"] {
|
||||
svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
flex: 1;
|
||||
font: var(--cpd-font-body-md-regular);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
min-width: 0;
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
@@ -28,6 +29,17 @@
|
||||
kbd {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Shrink and truncate the search text */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
.mx_RoomListSearch_search_text {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,16 +10,3 @@
|
||||
margin: var(--cpd-space-2x);
|
||||
margin-left: var(--cpd-space-1x);
|
||||
}
|
||||
|
||||
.mx_RoomListSecondaryFilters_roomOptionsButton {
|
||||
/* Size the button appropriately (should this be in em, maybe,
|
||||
* so it gets bigger with font size? These values taken from the figma.
|
||||
*/
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-left: auto;
|
||||
|
||||
svg {
|
||||
color: var(--cpd-color-icon-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,5 +103,5 @@ Please see LICENSE files in the repository root for full details.
|
||||
}
|
||||
|
||||
.mx_RoomHeader .mx_RoomHeader_toggled {
|
||||
color: var(--cpd-color-icon-accent-primary);
|
||||
fill: var(--cpd-color-icon-accent-primary);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* voodoo where we have to set display: none by default
|
||||
*/
|
||||
|
||||
h1::after {
|
||||
.mx_Header_title::after {
|
||||
content: "!";
|
||||
}
|
||||
|
||||
|
||||
11
src/@types/global.d.ts
vendored
@@ -128,8 +128,19 @@ declare global {
|
||||
}
|
||||
|
||||
interface Electron {
|
||||
// Legacy
|
||||
on(channel: ElectronChannel, listener: (event: Event, ...args: any[]) => void): void;
|
||||
send(channel: ElectronChannel, ...args: any[]): void;
|
||||
// Initialisation
|
||||
initialise(): Promise<{
|
||||
protocol: string;
|
||||
sessionId: string;
|
||||
config: IConfigOptions;
|
||||
supportedSettings: Record<string, boolean>;
|
||||
}>;
|
||||
// Settings
|
||||
setSettingValue(settingName: string, value: any): Promise<void>;
|
||||
getSettingValue(settingName: string): Promise<any>;
|
||||
}
|
||||
|
||||
interface DesktopCapturerSource {
|
||||
|
||||
29
src/@types/invite-rules.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
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 const INVITE_RULES_ACCOUNT_DATA_TYPE = "org.matrix.msc4155.invite_permission_config";
|
||||
|
||||
export interface InviteConfigAccountData {
|
||||
allowed_users?: string[];
|
||||
blocked_users?: string[];
|
||||
ignored_users?: string[];
|
||||
allowed_servers?: string[];
|
||||
blocked_servers?: string[];
|
||||
ignored_servers?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed values based on MSC4155. Currently Element Web only supports
|
||||
* blocking all invites.
|
||||
*/
|
||||
export interface ComputedInviteConfig extends Record<string, unknown> {
|
||||
/**
|
||||
* Are all invites blocked. This is only about blocking all invites,
|
||||
* but this being false may still block invites through other rules.
|
||||
*/
|
||||
allBlocked: boolean;
|
||||
}
|
||||
4
src/@types/matrix-js-sdk.d.ts
vendored
@@ -15,6 +15,7 @@ import type { EmptyObject } from "matrix-js-sdk/src/matrix";
|
||||
import type { DeviceClientInformation } from "../utils/device/types.ts";
|
||||
import type { UserWidget } from "../utils/WidgetUtils-types.ts";
|
||||
import { type MediaPreviewConfig } from "./media_preview.ts";
|
||||
import { type INVITE_RULES_ACCOUNT_DATA_TYPE, type InviteConfigAccountData } from "./invite-rules.ts";
|
||||
|
||||
// Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types
|
||||
declare module "matrix-js-sdk/src/types" {
|
||||
@@ -60,7 +61,6 @@ declare module "matrix-js-sdk/src/types" {
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface AccountDataEvents {
|
||||
// Analytics account data event
|
||||
"im.vector.analytics": {
|
||||
@@ -89,6 +89,8 @@ declare module "matrix-js-sdk/src/types" {
|
||||
accepted: string[];
|
||||
};
|
||||
|
||||
// MSC4155: Invite filtering
|
||||
[INVITE_RULES_ACCOUNT_DATA_TYPE]: InviteConfigAccountData;
|
||||
"io.element.msc4278.media_preview_config": MediaPreviewConfig;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ import { blobIsAnimated } from "./utils/Image.ts";
|
||||
const PHYS_HIDPI = [0x00, 0x00, 0x16, 0x25, 0x00, 0x00, 0x16, 0x25, 0x01];
|
||||
|
||||
export class UploadCanceledError extends Error {}
|
||||
export class UploadFailedError extends Error {}
|
||||
|
||||
interface IMediaConfig {
|
||||
"m.upload.size"?: number;
|
||||
@@ -355,12 +356,19 @@ export async function uploadFile(
|
||||
// Pass the encrypted data as a Blob to the uploader.
|
||||
const blob = new Blob([encryptResult.data]);
|
||||
|
||||
const { content_uri: url } = await matrixClient.uploadContent(blob, {
|
||||
progressHandler,
|
||||
abortController,
|
||||
includeFilename: false,
|
||||
type: "application/octet-stream",
|
||||
});
|
||||
let url: string;
|
||||
try {
|
||||
({ content_uri: url } = await matrixClient.uploadContent(blob, {
|
||||
progressHandler,
|
||||
abortController,
|
||||
includeFilename: false,
|
||||
type: "application/octet-stream",
|
||||
}));
|
||||
} catch (e) {
|
||||
if (abortController.signal.aborted) throw new UploadCanceledError();
|
||||
console.error("Failed to upload file", e);
|
||||
throw new UploadFailedError();
|
||||
}
|
||||
if (abortController.signal.aborted) throw new UploadCanceledError();
|
||||
|
||||
// If the attachment is encrypted then bundle the URL along with the information
|
||||
@@ -372,7 +380,14 @@ export async function uploadFile(
|
||||
} as EncryptedFile,
|
||||
};
|
||||
} else {
|
||||
const { content_uri: url } = await matrixClient.uploadContent(file, { progressHandler, abortController });
|
||||
let url: string;
|
||||
try {
|
||||
({ content_uri: url } = await matrixClient.uploadContent(file, { progressHandler, abortController }));
|
||||
} catch (e) {
|
||||
if (abortController.signal.aborted) throw new UploadCanceledError();
|
||||
console.error("Failed to upload file", e);
|
||||
throw new UploadFailedError();
|
||||
}
|
||||
if (abortController.signal.aborted) throw new UploadCanceledError();
|
||||
// If the attachment isn't encrypted then include the URL directly.
|
||||
return { url };
|
||||
@@ -570,7 +585,7 @@ export default class ContentMessages {
|
||||
const imageInfo = await infoForImageFile(matrixClient, roomId, file);
|
||||
Object.assign(content.info, imageInfo);
|
||||
} catch (e) {
|
||||
if (e instanceof HTTPError) {
|
||||
if (e instanceof UploadFailedError) {
|
||||
// re-throw to main upload error handler
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -38,10 +38,10 @@ export async function createCrossSigning(cli: MatrixClient): Promise<void> {
|
||||
|
||||
export async function uiAuthCallback(
|
||||
matrixClient: MatrixClient,
|
||||
makeRequest: (authData: AuthDict) => Promise<void>,
|
||||
makeRequest: (authData: AuthDict | null) => Promise<void>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await makeRequest({});
|
||||
await makeRequest(null);
|
||||
} catch (error) {
|
||||
if (!(error instanceof MatrixError) || !error.data || !error.data.flows) {
|
||||
// Not a UIA response
|
||||
|
||||
@@ -97,6 +97,7 @@ export default class DeviceListener {
|
||||
this.client.on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
|
||||
this.client.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
|
||||
this.client.on(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged);
|
||||
this.client.on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatusChanged);
|
||||
this.client.on(ClientEvent.AccountData, this.onAccountData);
|
||||
this.client.on(ClientEvent.Sync, this.onSync);
|
||||
this.client.on(RoomStateEvent.Events, this.onRoomStateEvents);
|
||||
@@ -132,7 +133,7 @@ export default class DeviceListener {
|
||||
this.dismissedThisDeviceToast = false;
|
||||
this.keyBackupInfo = null;
|
||||
this.keyBackupFetchedAt = null;
|
||||
this.cachedKeyBackupStatus = undefined;
|
||||
this.cachedKeyBackupUploadActive = undefined;
|
||||
this.ourDeviceIdsAtStart = null;
|
||||
this.displayingToastsForDeviceIds = new Set();
|
||||
this.client = undefined;
|
||||
@@ -157,6 +158,13 @@ export default class DeviceListener {
|
||||
this.recheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the account data "m.org.matrix.custom.backup_disabled" to { "disabled": true }.
|
||||
*/
|
||||
public async recordKeyBackupDisabled(): Promise<void> {
|
||||
await this.client?.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true });
|
||||
}
|
||||
|
||||
private async ensureDeviceIdsAtStartPopulated(): Promise<void> {
|
||||
if (this.ourDeviceIdsAtStart === null) {
|
||||
this.ourDeviceIdsAtStart = await this.getDeviceIds();
|
||||
@@ -192,6 +200,11 @@ export default class DeviceListener {
|
||||
this.recheck();
|
||||
};
|
||||
|
||||
private onKeyBackupStatusChanged = (): void => {
|
||||
this.cachedKeyBackupUploadActive = undefined;
|
||||
this.recheck();
|
||||
};
|
||||
|
||||
private onCrossSingingKeysChanged = (): void => {
|
||||
this.recheck();
|
||||
};
|
||||
@@ -201,11 +214,13 @@ export default class DeviceListener {
|
||||
// * migrated SSSS to symmetric
|
||||
// * uploaded keys to secret storage
|
||||
// * completed secret storage creation
|
||||
// * disabled key backup
|
||||
// which result in account data changes affecting checks below.
|
||||
if (
|
||||
ev.getType().startsWith("m.secret_storage.") ||
|
||||
ev.getType().startsWith("m.cross_signing.") ||
|
||||
ev.getType() === "m.megolm_backup.v1"
|
||||
ev.getType() === "m.megolm_backup.v1" ||
|
||||
ev.getType() === BACKUP_DISABLED_ACCOUNT_DATA_KEY
|
||||
) {
|
||||
this.recheck();
|
||||
}
|
||||
@@ -324,7 +339,16 @@ export default class DeviceListener {
|
||||
(await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified,
|
||||
);
|
||||
|
||||
const allSystemsReady = crossSigningReady && secretStorageReady && allCrossSigningSecretsCached;
|
||||
const keyBackupUploadActive = await this.isKeyBackupUploadActive();
|
||||
const backupDisabled = await this.recheckBackupDisabled(cli);
|
||||
|
||||
// We warn if key backup upload is turned off and we have not explicitly
|
||||
// said we are OK with that.
|
||||
const keyBackupIsOk = keyBackupUploadActive || backupDisabled;
|
||||
|
||||
const allSystemsReady =
|
||||
crossSigningReady && keyBackupIsOk && secretStorageReady && allCrossSigningSecretsCached;
|
||||
|
||||
await this.reportCryptoSessionStateToAnalytics(cli);
|
||||
|
||||
if (this.dismissedThisDeviceToast || allSystemsReady) {
|
||||
@@ -353,14 +377,19 @@ export default class DeviceListener {
|
||||
crossSigningStatus.privateKeysCachedLocally,
|
||||
);
|
||||
showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC);
|
||||
} else if (!keyBackupIsOk) {
|
||||
logSpan.info("Key backup upload is unexpectedly turned off: showing TURN_ON_KEY_STORAGE toast");
|
||||
showSetupEncryptionToast(SetupKind.TURN_ON_KEY_STORAGE);
|
||||
} else if (defaultKeyId === null) {
|
||||
// the user just hasn't set up 4S yet: prompt them to do so (unless they've explicitly said no to key storage)
|
||||
const disabledEvent = cli.getAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY);
|
||||
if (!disabledEvent?.getContent().disabled) {
|
||||
// The user just hasn't set up 4S yet: if they have key
|
||||
// backup, prompt them to turn on recovery too. (If not, they
|
||||
// have explicitly opted out, so don't hassle them.)
|
||||
if (keyBackupUploadActive) {
|
||||
logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast");
|
||||
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
|
||||
} else {
|
||||
logSpan.info("No default 4S key but backup disabled: no toast needed");
|
||||
hideSetupEncryptionToast();
|
||||
}
|
||||
} else {
|
||||
// some other condition... yikes! Show the 'set up encryption' toast: this is what we previously did
|
||||
@@ -443,6 +472,16 @@ export default class DeviceListener {
|
||||
this.displayingToastsForDeviceIds = newUnverifiedDeviceIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the account data for `backup_disabled`. If this is the first time,
|
||||
* fetch it from the server (in case the initial sync has not finished).
|
||||
* Otherwise, fetch it from the store as normal.
|
||||
*/
|
||||
private async recheckBackupDisabled(cli: MatrixClient): Promise<boolean> {
|
||||
const backupDisabled = await cli.getAccountDataFromServer(BACKUP_DISABLED_ACCOUNT_DATA_KEY);
|
||||
return !!backupDisabled?.disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports current recovery state to analytics.
|
||||
* Checks if the session is verified and if the recovery is correctly set up (i.e all secrets known locally and in 4S).
|
||||
@@ -512,7 +551,7 @@ export default class DeviceListener {
|
||||
* trigger an auto-rageshake).
|
||||
*/
|
||||
private checkKeyBackupStatus = async (): Promise<void> => {
|
||||
if (!(await this.getKeyBackupStatus())) {
|
||||
if (!(await this.isKeyBackupUploadActive())) {
|
||||
dis.dispatch({ action: Action.ReportKeyBackupNotEnabled });
|
||||
}
|
||||
};
|
||||
@@ -520,28 +559,34 @@ export default class DeviceListener {
|
||||
/**
|
||||
* Is key backup enabled? Use a cached answer if we have one.
|
||||
*/
|
||||
private getKeyBackupStatus = async (): Promise<boolean> => {
|
||||
private isKeyBackupUploadActive = async (): Promise<boolean> => {
|
||||
if (!this.client) {
|
||||
// To preserve existing behaviour, if there is no client, we
|
||||
// pretend key storage is on.
|
||||
// pretend key backup upload is on.
|
||||
//
|
||||
// Someone looking to improve this code could try throwing an error
|
||||
// here since we don't expect client to be undefined.
|
||||
return true;
|
||||
}
|
||||
|
||||
const crypto = this.client.getCrypto();
|
||||
if (!crypto) {
|
||||
// If there is no crypto, there is no key backup
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we've already cached the answer, return it.
|
||||
if (this.cachedKeyBackupStatus !== undefined) {
|
||||
return this.cachedKeyBackupStatus;
|
||||
if (this.cachedKeyBackupUploadActive !== undefined) {
|
||||
return this.cachedKeyBackupUploadActive;
|
||||
}
|
||||
|
||||
// Fetch the answer and cache it
|
||||
const activeKeyBackupVersion = await this.client.getCrypto()?.getActiveSessionBackupVersion();
|
||||
this.cachedKeyBackupStatus = !!activeKeyBackupVersion;
|
||||
const activeKeyBackupVersion = await crypto.getActiveSessionBackupVersion();
|
||||
this.cachedKeyBackupUploadActive = !!activeKeyBackupVersion;
|
||||
|
||||
return this.cachedKeyBackupStatus;
|
||||
return this.cachedKeyBackupUploadActive;
|
||||
};
|
||||
private cachedKeyBackupStatus: boolean | undefined = undefined;
|
||||
private cachedKeyBackupUploadActive: boolean | undefined = undefined;
|
||||
|
||||
private onRecordClientInformationSettingChange: CallbackFn = (
|
||||
_originalSettingName,
|
||||
|
||||
@@ -657,7 +657,7 @@ export async function restoreSessionFromStorage(opts?: { ignoreGuest?: boolean }
|
||||
freshLogin: freshLogin,
|
||||
},
|
||||
false,
|
||||
false,
|
||||
freshLogin,
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
|
||||
@@ -19,7 +19,6 @@ import AccessSecretStorageDialog, {
|
||||
type KeyParams,
|
||||
} from "./components/views/dialogs/security/AccessSecretStorageDialog";
|
||||
import { ModuleRunner } from "./modules/ModuleRunner";
|
||||
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
|
||||
import InteractiveAuthDialog from "./components/views/dialogs/InteractiveAuthDialog";
|
||||
|
||||
// This stores the secret storage private keys in memory for the JS SDK. This is
|
||||
@@ -50,17 +49,6 @@ export class AccessCancelledError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmToDismiss(): Promise<boolean> {
|
||||
const [sure] = await Modal.createDialog(QuestionDialog, {
|
||||
title: _t("encryption|cancel_entering_passphrase_title"),
|
||||
description: _t("encryption|cancel_entering_passphrase_description"),
|
||||
danger: false,
|
||||
button: _t("action|go_back"),
|
||||
cancelButton: _t("action|cancel"),
|
||||
}).finished;
|
||||
return !sure;
|
||||
}
|
||||
|
||||
function makeInputToKey(
|
||||
keyInfo: SecretStorage.SecretStorageKeyDescription,
|
||||
): (keyParams: KeyParams) => Promise<Uint8Array> {
|
||||
@@ -134,17 +122,6 @@ async function getSecretStorageKey(
|
||||
return MatrixClientPeg.safeGet().secretStorage.checkKey(key, keyInfo);
|
||||
},
|
||||
},
|
||||
/* className= */ undefined,
|
||||
/* isPriorityModal= */ false,
|
||||
/* isStaticModal= */ false,
|
||||
/* options= */ {
|
||||
onBeforeClose: async (reason): Promise<boolean> => {
|
||||
if (reason === "backgroundClick") {
|
||||
return confirmToDismiss();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
const [keyParams] = await finished;
|
||||
if (!keyParams) {
|
||||
|
||||
@@ -60,6 +60,7 @@ import { deop, op } from "./slash-commands/op";
|
||||
import { CommandCategories } from "./slash-commands/interface";
|
||||
import { Command } from "./slash-commands/command";
|
||||
import { goto, join } from "./slash-commands/join";
|
||||
import { manuallyVerifyDevice } from "./components/views/dialogs/ManualDeviceKeyVerificationDialog";
|
||||
|
||||
export { CommandCategories, Command };
|
||||
|
||||
@@ -663,6 +664,36 @@ export const Commands = [
|
||||
category: CommandCategories.admin,
|
||||
renderingTypes: [TimelineRenderingType.Room],
|
||||
}),
|
||||
new Command({
|
||||
command: "verify",
|
||||
args: "<device-id> <device-fingerprint>",
|
||||
description: _td("slash_command|verify"),
|
||||
runFn: function (cli, _roomId, _threadId, args) {
|
||||
if (args) {
|
||||
const matches = args.match(/^(\S+) +(\S+)$/);
|
||||
if (matches) {
|
||||
const deviceId = matches[1];
|
||||
const fingerprint = matches[2];
|
||||
|
||||
const { finished } = Modal.createDialog(QuestionDialog, {
|
||||
title: _t("slash_command|manual_device_verification_confirm_title"),
|
||||
description: _t("slash_command|manual_device_verification_confirm_description"),
|
||||
button: _t("action|verify"),
|
||||
danger: true,
|
||||
});
|
||||
|
||||
return success(
|
||||
finished.then(([confirmed]) => {
|
||||
if (confirmed) manuallyVerifyDevice(cli, deviceId, fingerprint);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return reject(this.getUsage());
|
||||
},
|
||||
category: CommandCategories.advanced,
|
||||
renderingTypes: [TimelineRenderingType.Room],
|
||||
}),
|
||||
new Command({
|
||||
command: "discardsession",
|
||||
description: _td("slash_command|discardsession"),
|
||||
|
||||
@@ -379,13 +379,14 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const useNewRoomList = SettingsStore.getValue("feature_new_room_list");
|
||||
const containerClasses = classNames({
|
||||
mx_LeftPanel: true,
|
||||
mx_LeftPanel_newRoomList: useNewRoomList,
|
||||
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}>
|
||||
|
||||
@@ -259,9 +259,11 @@ class LoggedInView extends React.Component<IProps, IState> {
|
||||
private createResizer(): Resizer<ICollapseConfig, CollapseItem> {
|
||||
let panelSize: number | null;
|
||||
let panelCollapsed: boolean;
|
||||
const useNewRoomList = SettingsStore.getValue("feature_new_room_list");
|
||||
// TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel
|
||||
const toggleSize = useNewRoomList ? 224 : 206 - 50;
|
||||
const collapseConfig: ICollapseConfig = {
|
||||
// TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel
|
||||
toggleSize: 206 - 50,
|
||||
toggleSize,
|
||||
onCollapsed: (collapsed) => {
|
||||
panelCollapsed = collapsed;
|
||||
if (collapsed) {
|
||||
@@ -697,10 +699,18 @@ class LoggedInView extends React.Component<IProps, IState> {
|
||||
"mx_MatrixChat--with-avatar": this.state.backgroundImage,
|
||||
});
|
||||
|
||||
const useNewRoomList = SettingsStore.getValue("feature_new_room_list");
|
||||
|
||||
const leftPanelWrapperClasses = classNames({
|
||||
mx_LeftPanel_wrapper: true,
|
||||
mx_LeftPanel_newRoomList: useNewRoomList,
|
||||
});
|
||||
|
||||
const audioFeedArraysForCalls = this.state.activeCalls.map((call) => {
|
||||
return <AudioFeedArrayForLegacyCall call={call} key={call.callId} />;
|
||||
});
|
||||
|
||||
const shouldUseMinimizedUI = !useNewRoomList && this.props.collapseLhs;
|
||||
return (
|
||||
<MatrixClientContextProvider client={this._matrixClient}>
|
||||
<div
|
||||
@@ -712,19 +722,21 @@ class LoggedInView extends React.Component<IProps, IState> {
|
||||
<ToastContainer />
|
||||
<div className={bodyClasses}>
|
||||
<div className="mx_LeftPanel_outerWrapper">
|
||||
<LeftPanelLiveShareWarning isMinimized={this.props.collapseLhs || false} />
|
||||
<div className="mx_LeftPanel_wrapper">
|
||||
<BackdropPanel blurMultiplier={0.5} backgroundImage={this.state.backgroundImage} />
|
||||
<LeftPanelLiveShareWarning isMinimized={shouldUseMinimizedUI || false} />
|
||||
<div className={leftPanelWrapperClasses}>
|
||||
{!useNewRoomList && (
|
||||
<BackdropPanel blurMultiplier={0.5} backgroundImage={this.state.backgroundImage} />
|
||||
)}
|
||||
<SpacePanel />
|
||||
<BackdropPanel backgroundImage={this.state.backgroundImage} />
|
||||
{!useNewRoomList && <BackdropPanel backgroundImage={this.state.backgroundImage} />}
|
||||
<div
|
||||
className="mx_LeftPanel_wrapper--user"
|
||||
ref={this._resizeContainer}
|
||||
data-collapsed={this.props.collapseLhs ? true : undefined}
|
||||
data-collapsed={shouldUseMinimizedUI ? true : undefined}
|
||||
>
|
||||
<LeftPanel
|
||||
pageType={this.props.page_type as PageTypes}
|
||||
isMinimized={this.props.collapseLhs || false}
|
||||
isMinimized={shouldUseMinimizedUI || false}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
EventType,
|
||||
HttpApiEvent,
|
||||
type MatrixClient,
|
||||
type MatrixEvent,
|
||||
MatrixEvent,
|
||||
MsgType,
|
||||
type RoomType,
|
||||
SyncState,
|
||||
type SyncStateData,
|
||||
@@ -24,9 +25,9 @@ import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { throttle } from "lodash";
|
||||
import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
// what-input helps improve keyboard accessibility
|
||||
import "what-input";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
import PosthogTrackers from "../../PosthogTrackers";
|
||||
import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
|
||||
@@ -50,6 +51,7 @@ import ThemeController from "../../settings/controllers/ThemeController";
|
||||
import { startAnyRegistrationFlow } from "../../Registration";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils";
|
||||
import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
|
||||
import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher";
|
||||
import { FontWatcher } from "../../settings/watchers/FontWatcher";
|
||||
import { storeRoomAliasInCache } from "../../RoomAliasCache";
|
||||
@@ -94,7 +96,6 @@ import VerificationRequestToast from "../views/toasts/VerificationRequestToast";
|
||||
import PerformanceMonitor, { PerformanceEntryNames } from "../../performance";
|
||||
import UIStore, { UI_EVENTS } from "../../stores/UIStore";
|
||||
import SoftLogout from "./auth/SoftLogout";
|
||||
import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
|
||||
import { copyPlaintext } from "../../utils/strings";
|
||||
import { PosthogAnalytics } from "../../PosthogAnalytics";
|
||||
import { initSentry } from "../../sentry";
|
||||
@@ -107,6 +108,7 @@ import Views from "../../Views";
|
||||
import { type FocusNextType, type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { type ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload";
|
||||
import { type AfterLeaveRoomPayload } from "../../dispatcher/payloads/AfterLeaveRoomPayload";
|
||||
import { type AfterForgetRoomPayload } from "../../dispatcher/payloads/AfterForgetRoomPayload";
|
||||
import { type DoAfterSyncPreparedPayload } from "../../dispatcher/payloads/DoAfterSyncPreparedPayload";
|
||||
import { type ViewStartChatOrReusePayload } from "../../dispatcher/payloads/ViewStartChatOrReusePayload";
|
||||
import { leaveRoomBehaviour } from "../../utils/leave-behaviour";
|
||||
@@ -123,7 +125,7 @@ import { viewUserDeviceSettings } from "../../actions/handlers/viewUserDeviceSet
|
||||
import GenericToast from "../views/toasts/GenericToast";
|
||||
import RovingSpotlightDialog from "../views/dialogs/spotlight/SpotlightDialog";
|
||||
import { findDMForUser } from "../../utils/dm/findDMForUser";
|
||||
import { Linkify } from "../../HtmlUtils";
|
||||
import { getHtmlText, Linkify } from "../../HtmlUtils";
|
||||
import { NotificationLevel } from "../../stores/notifications/NotificationLevel";
|
||||
import { type UserTab } from "../views/dialogs/UserTab";
|
||||
import { shouldSkipSetupEncryption } from "../../utils/crypto/shouldSkipSetupEncryption";
|
||||
@@ -135,6 +137,10 @@ import { LoginSplashView } from "./auth/LoginSplashView";
|
||||
import { cleanUpDraftsIfRequired } from "../../DraftCleaner";
|
||||
import { InitialCryptoSetupStore } from "../../stores/InitialCryptoSetupStore";
|
||||
import { setTheme } from "../../theme";
|
||||
import { type OpenForwardDialogPayload } from "../../dispatcher/payloads/OpenForwardDialogPayload";
|
||||
import { ShareFormat, type SharePayload } from "../../dispatcher/payloads/SharePayload";
|
||||
import Markdown from "../../Markdown";
|
||||
import { sanitizeHtmlParams } from "../../Linkify";
|
||||
|
||||
// legacy export
|
||||
export { default as Views } from "../../Views";
|
||||
@@ -779,6 +785,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
case Action.ViewHomePage:
|
||||
this.viewHome(payload.justRegistered);
|
||||
break;
|
||||
case Action.Share:
|
||||
this.viewShare(payload.format, payload.msg);
|
||||
break;
|
||||
case Action.ViewStartChatOrReuse:
|
||||
this.chatCreateOrReuse(payload.user_id);
|
||||
break;
|
||||
@@ -1114,6 +1123,58 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
});
|
||||
}
|
||||
|
||||
private viewShare(format: ShareFormat, msg: string): void {
|
||||
// Wait for the first sync so we can present possible rooms to share into
|
||||
this.firstSyncPromise.promise.then(() => {
|
||||
this.notifyNewScreen("share");
|
||||
let rawEvent;
|
||||
switch (format) {
|
||||
case ShareFormat.Html: {
|
||||
rawEvent = {
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
msgtype: MsgType.Text,
|
||||
body: getHtmlText(msg),
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: sanitizeHtml(msg, sanitizeHtmlParams),
|
||||
},
|
||||
origin_server_ts: Date.now(),
|
||||
};
|
||||
break;
|
||||
}
|
||||
case ShareFormat.Markdown: {
|
||||
const html = new Markdown(msg).toHTML({ externalLinks: true });
|
||||
rawEvent = {
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
msgtype: MsgType.Text,
|
||||
body: msg,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html,
|
||||
},
|
||||
origin_server_ts: Date.now(),
|
||||
};
|
||||
break;
|
||||
}
|
||||
default:
|
||||
rawEvent = {
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
msgtype: MsgType.Text,
|
||||
body: msg,
|
||||
},
|
||||
origin_server_ts: Date.now(),
|
||||
};
|
||||
}
|
||||
const event = new MatrixEvent(rawEvent);
|
||||
dis.dispatch<OpenForwardDialogPayload>({
|
||||
action: Action.OpenForwardDialog,
|
||||
event: event,
|
||||
permalinkCreator: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async createRoom(defaultPublic = false, defaultName?: string, type?: RoomType): Promise<void> {
|
||||
const modal = Modal.createDialog(CreateRoomDialog, {
|
||||
type,
|
||||
@@ -1269,10 +1330,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
dis.dispatch({ action: Action.ViewHomePage });
|
||||
}
|
||||
|
||||
// We have to manually update the room list because the forgotten room will not
|
||||
// be notified to us, therefore the room list will have no other way of knowing
|
||||
// the room is forgotten.
|
||||
if (room) RoomListStore.instance.manualRoomUpdate(room, RoomUpdateCause.RoomRemoved);
|
||||
if (room) {
|
||||
// Legacy room list store needs to be told to manually remove this room
|
||||
RoomListStore.instance.manualRoomUpdate(room, RoomUpdateCause.RoomRemoved);
|
||||
// New room list store will remove the room on the following dispatch
|
||||
dis.dispatch<AfterForgetRoomPayload>({ action: Action.AfterForgetRoom, room });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const errCode = err.errcode || _td("error|unknown_error_code");
|
||||
@@ -1739,6 +1802,20 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
dis.dispatch({
|
||||
action: Action.CreateChat,
|
||||
});
|
||||
} else if (screen === "share") {
|
||||
if (params && params["msg"] !== undefined) {
|
||||
dis.dispatch<SharePayload>({
|
||||
action: Action.Share,
|
||||
msg: params["msg"],
|
||||
format: params["format"],
|
||||
});
|
||||
}
|
||||
// if we weren't already coming at this from an existing screen
|
||||
// and we're logged in, then explicitly default to home.
|
||||
// if we're not logged in, then the login flow will do the right thing.
|
||||
if (!this.state.currentRoomId && !this.state.currentUserId) {
|
||||
this.viewHome();
|
||||
}
|
||||
} else if (screen === "settings") {
|
||||
dis.fire(Action.ViewUserSettings);
|
||||
} else if (screen === "welcome") {
|
||||
|
||||
@@ -15,7 +15,7 @@ import dis from "../../dispatcher/dispatcher";
|
||||
import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases";
|
||||
import RightPanelStore from "../../stores/right-panel/RightPanelStore";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import RoomSummaryCard from "../views/right_panel/RoomSummaryCard";
|
||||
import RoomSummaryCardView from "../views/right_panel/RoomSummaryCardView";
|
||||
import WidgetCard from "../views/right_panel/WidgetCard";
|
||||
import UserInfo from "../views/right_panel/UserInfo";
|
||||
import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo";
|
||||
@@ -255,7 +255,7 @@ export default class RightPanel extends React.Component<Props, IState> {
|
||||
case RightPanelPhases.RoomSummary:
|
||||
if (!!this.props.room) {
|
||||
card = (
|
||||
<RoomSummaryCard
|
||||
<RoomSummaryCardView
|
||||
room={this.props.room}
|
||||
// whenever RightPanel is passed a room it is passed a permalinkcreator
|
||||
permalinkCreator={this.props.permalinkCreator!}
|
||||
@@ -282,7 +282,7 @@ export default class RightPanel extends React.Component<Props, IState> {
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="mx_RightPanel" id="mx_RightPanel">
|
||||
<aside className="mx_RightPanel" id="mx_RightPanel" data-testid="right-panel">
|
||||
{card}
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -315,7 +315,7 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement {
|
||||
<div className="mx_RoomView mx_RoomView--local">
|
||||
<ErrorBoundary>
|
||||
<RoomHeader room={room} />
|
||||
<main className="mx_RoomView_body" ref={props.roomView}>
|
||||
<main className="mx_RoomView_body" ref={props.roomView} aria-label={_t("room|room_content")}>
|
||||
<FileDropTarget parent={props.roomView.current} onFileDrop={props.onFileDrop} />
|
||||
<div className="mx_RoomView_timeline">
|
||||
<ScrollPanel className="mx_RoomView_messagePanel" resizeNotifier={props.resizeNotifier}>
|
||||
@@ -370,6 +370,10 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
private unmounted = false;
|
||||
private permalinkCreators: Record<string, RoomPermalinkCreator> = {};
|
||||
|
||||
// The userId from which we received this invite.
|
||||
// Only populated if the membership of our user is invite.
|
||||
private inviter?: string;
|
||||
|
||||
private roomView = createRef<HTMLDivElement>();
|
||||
private searchResultsPanel = createRef<ScrollPanel>();
|
||||
private messagePanel: TimelinePanel | null = null;
|
||||
@@ -1350,6 +1354,11 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
// after a successful peek, or after we join the room).
|
||||
private onRoomLoaded = (room: Room): void => {
|
||||
if (this.unmounted) return;
|
||||
|
||||
// Store the inviter so that we can know who invited us to this room even if
|
||||
// the membership event changes.
|
||||
this.inviter = this.getInviterFromRoom(room);
|
||||
|
||||
// Attach a widget store listener only when we get a room
|
||||
this.context.widgetLayoutStore.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange);
|
||||
|
||||
@@ -1729,8 +1738,20 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
});
|
||||
};
|
||||
|
||||
private getInviterFromRoom(room: Room): string | undefined {
|
||||
const ownUserId = this.context.client?.getSafeUserId();
|
||||
if (!ownUserId) return;
|
||||
|
||||
const myMember = room.getMember(ownUserId);
|
||||
const memberEvent = myMember?.events.member;
|
||||
const senderId = memberEvent?.getSender();
|
||||
|
||||
if (memberEvent?.getContent().membership === KnownMembership.Invite) return senderId;
|
||||
}
|
||||
|
||||
private onDeclineAndBlockButtonClicked = async (): Promise<void> => {
|
||||
if (!this.state.room || !this.context.client) return;
|
||||
|
||||
const [shouldReject, ignoreUser, reportRoom] = await Modal.createDialog(DeclineAndBlockInviteDialog, {
|
||||
roomName: this.state.room.name,
|
||||
}).finished;
|
||||
@@ -1745,11 +1766,20 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
const actions: Promise<unknown>[] = [];
|
||||
|
||||
if (ignoreUser) {
|
||||
const myMember = this.state.room.getMember(this.context.client!.getSafeUserId());
|
||||
const inviteEvent = myMember!.events.member;
|
||||
const ignoredUsers = this.context.client.getIgnoredUsers();
|
||||
ignoredUsers.push(inviteEvent!.getSender()!); // de-duped internally in the js-sdk
|
||||
actions.push(this.context.client.setIgnoredUsers(ignoredUsers));
|
||||
const doIgnore = async (): Promise<void> => {
|
||||
const ownUserId = this.context.client!.getSafeUserId();
|
||||
if (!this.inviter || this.inviter === ownUserId) {
|
||||
// This is unlikely to happen since we cache the inviter as early as possible.
|
||||
// However, we still do this check here to be double sure.
|
||||
throw new CannotDetermineUserError(
|
||||
"Cannot determine which user to ignore since the member event has changed.",
|
||||
);
|
||||
}
|
||||
const ignoredUsers = this.context.client!.getIgnoredUsers();
|
||||
ignoredUsers.push(this.inviter); // de-duped internally in the js-sdk
|
||||
await this.context.client!.setIgnoredUsers(ignoredUsers);
|
||||
};
|
||||
actions.push(doIgnore());
|
||||
}
|
||||
|
||||
if (reportRoom !== false) {
|
||||
@@ -1766,7 +1796,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
} catch (error) {
|
||||
logger.error(`Failed to reject invite: ${error}`);
|
||||
|
||||
const msg = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
let msg: string = "";
|
||||
if (error instanceof CannotDetermineUserError) {
|
||||
msg = _t("room|failed_determine_user");
|
||||
} else if (error instanceof Error) {
|
||||
msg = error.message;
|
||||
} else {
|
||||
msg = JSON.stringify(error);
|
||||
}
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("room|failed_reject_invite"),
|
||||
description: msg,
|
||||
@@ -1783,6 +1820,9 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.setState({
|
||||
rejecting: true,
|
||||
});
|
||||
await this.context.client.leave(this.state.room.roomId);
|
||||
defaultDispatcher.dispatch({ action: Action.ViewHomePage });
|
||||
this.setState({
|
||||
@@ -2612,3 +2652,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CannotDetermineUserError extends Error {
|
||||
public name = "CannotDetermineUserError";
|
||||
}
|
||||
|
||||
@@ -10,26 +10,26 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
import { useDmMember, usePresence, type Presence } from "../../views/avatars/WithPresenceIndicator";
|
||||
import { DefaultTagID } from "../../../stores/room-list/models";
|
||||
|
||||
export enum AvatarBadgeDecoration {
|
||||
LowPriority = "LowPriority",
|
||||
VideoRoom = "VideoRoom",
|
||||
PublicRoom = "PublicRoom",
|
||||
Presence = "Presence",
|
||||
}
|
||||
|
||||
export interface RoomAvatarViewState {
|
||||
/**
|
||||
* Whether the room avatar has a decoration.
|
||||
* A decoration can be a public or a video call icon or an indicator of presence.
|
||||
*/
|
||||
hasDecoration: boolean;
|
||||
/**
|
||||
* Whether the room is public.
|
||||
*/
|
||||
isPublic: boolean;
|
||||
/**
|
||||
* Whether the room is a video room.
|
||||
*/
|
||||
isVideoRoom: boolean;
|
||||
/**
|
||||
* The presence of the user in the DM room.
|
||||
* If null, the user is not in a DM room or presence is not enabled.
|
||||
*/
|
||||
presence: Presence | null;
|
||||
|
||||
/**
|
||||
* The decoration that should be rendered.
|
||||
*/
|
||||
badgeDecoration?: AvatarBadgeDecoration;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,10 +41,20 @@ export function useRoomAvatarViewModel(room: Room): RoomAvatarViewState {
|
||||
const roomMember = useDmMember(room);
|
||||
const presence = usePresence(room, roomMember);
|
||||
const isPublic = useIsPublic(room);
|
||||
const isLowPriority = !!room.tags[DefaultTagID.LowPriority];
|
||||
|
||||
const hasDecoration = isPublic || isVideoRoom || presence !== null;
|
||||
let badgeDecoration: AvatarBadgeDecoration | undefined;
|
||||
if (isLowPriority) {
|
||||
badgeDecoration = AvatarBadgeDecoration.LowPriority;
|
||||
} else if (isVideoRoom) {
|
||||
badgeDecoration = AvatarBadgeDecoration.VideoRoom;
|
||||
} else if (isPublic) {
|
||||
badgeDecoration = AvatarBadgeDecoration.PublicRoom;
|
||||
} else if (presence) {
|
||||
badgeDecoration = AvatarBadgeDecoration.Presence;
|
||||
}
|
||||
|
||||
return { hasDecoration, isPublic, isVideoRoom, presence };
|
||||
return { badgeDecoration, presence };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
Copyright 2025 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 { useEffect, useRef, useState } from "react";
|
||||
import { EventType, type JoinRule, type Room, RoomStateEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useIsEncrypted } from "../../../hooks/useIsEncrypted";
|
||||
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext";
|
||||
import { type E2EStatus } from "../../../utils/ShieldUtils";
|
||||
import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms";
|
||||
import { useRoomState } from "../../../hooks/useRoomState";
|
||||
import { useAccountData } from "../../../hooks/useAccountData";
|
||||
import { useDispatcher } from "../../../hooks/useDispatcher";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
|
||||
import { canInviteTo } from "../../../utils/room/canInviteTo";
|
||||
import { DefaultTagID } from "../../../stores/room-list/models";
|
||||
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
||||
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
||||
import PosthogTrackers from "../../../PosthogTrackers";
|
||||
import { PollHistoryDialog } from "../../views/dialogs/PollHistoryDialog";
|
||||
import Modal from "../../../Modal";
|
||||
import ExportDialog from "../../views/dialogs/ExportDialog";
|
||||
import { ShareDialog } from "../../views/dialogs/ShareDialog";
|
||||
import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import { ReportRoomDialog } from "../../views/dialogs/ReportRoomDialog";
|
||||
import { Key } from "../../../Keyboard";
|
||||
import { usePinnedEvents } from "../../../hooks/usePinnedEvents";
|
||||
import { tagRoom } from "../../../utils/room/tagRoom";
|
||||
import { inviteToRoom } from "../../../utils/room/inviteToRoom";
|
||||
|
||||
export interface RoomSummaryCardState {
|
||||
isDirectMessage: boolean;
|
||||
/**
|
||||
* Whether the room is encrypted, used to display the correct badge and icon
|
||||
*/
|
||||
isRoomEncrypted: boolean;
|
||||
/**
|
||||
* The e2e status of the room, used to display the correct badge and icon
|
||||
*/
|
||||
e2eStatus: E2EStatus | undefined;
|
||||
/**
|
||||
* The join rule of the room, used to display the correct badge and icon
|
||||
*/
|
||||
roomJoinRule: JoinRule;
|
||||
/**
|
||||
* if it is a video room, it should not display export chat, polls, files, extensions
|
||||
*/
|
||||
isVideoRoom: boolean;
|
||||
/**
|
||||
* display the alias of the room, if it exists
|
||||
*/
|
||||
alias: string;
|
||||
/**
|
||||
* value to check if the room is a favorite or not
|
||||
*/
|
||||
isFavorite: boolean;
|
||||
/**
|
||||
* value to check if we disable invite button or not
|
||||
*/
|
||||
canInviteToState: boolean;
|
||||
/**
|
||||
* Getting the number of pinned messages in the room, next to the pin button
|
||||
*/
|
||||
pinCount: number;
|
||||
searchInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
/**
|
||||
* The callback when new value is entered in the search input
|
||||
*/
|
||||
onUpdateSearchInput: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
/**
|
||||
* Callbacks to all the actions button in the right panel
|
||||
*/
|
||||
onRoomMembersClick: () => void;
|
||||
onRoomThreadsClick: () => void;
|
||||
onRoomFilesClick: () => void;
|
||||
onRoomExtensionsClick: () => void;
|
||||
onRoomPinsClick: () => void;
|
||||
onRoomSettingsClick: (ev: Event) => void;
|
||||
onLeaveRoomClick: () => void;
|
||||
onShareRoomClick: () => void;
|
||||
onRoomExportClick: () => Promise<void>;
|
||||
onRoomPollHistoryClick: () => void;
|
||||
onReportRoomClick: () => Promise<void>;
|
||||
onFavoriteToggleClick: () => void;
|
||||
onInviteToRoomClick: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if the room is a direct message or not
|
||||
* @param room - The room to check
|
||||
* @returns Whether the room is a direct message
|
||||
*/
|
||||
const useIsDirectMessage = (room: Room): boolean => {
|
||||
const directRoomsList = useAccountData<Record<string, string[]>>(room.client, EventType.Direct);
|
||||
const [isDirectMessage, setDirectMessage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
for (const [, dmRoomList] of Object.entries(directRoomsList)) {
|
||||
if (dmRoomList.includes(room?.roomId ?? "")) {
|
||||
setDirectMessage(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [room, directRoomsList]);
|
||||
|
||||
return isDirectMessage;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to handle the search input in the right panel
|
||||
* @param onSearchCancel - The callback when the search input is cancelled
|
||||
* @returns The search input ref and the callback when the search input is updated
|
||||
*/
|
||||
const useSearchInput = (
|
||||
onSearchCancel?: () => void,
|
||||
): {
|
||||
searchInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
onUpdateSearchInput: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
} => {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onUpdateSearchInput = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (searchInputRef.current && e.key === Key.ESCAPE) {
|
||||
searchInputRef.current.value = "";
|
||||
onSearchCancel?.();
|
||||
}
|
||||
};
|
||||
|
||||
// Focus the search field when the user clicks on the search button component
|
||||
useDispatcher(defaultDispatcher, (payload) => {
|
||||
if (payload.action === Action.FocusMessageSearch) {
|
||||
searchInputRef.current?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
searchInputRef,
|
||||
onUpdateSearchInput,
|
||||
};
|
||||
};
|
||||
|
||||
export function useRoomSummaryCardViewModel(
|
||||
room: Room,
|
||||
permalinkCreator: RoomPermalinkCreator,
|
||||
onSearchCancel?: () => void,
|
||||
): RoomSummaryCardState {
|
||||
const cli = useMatrixClientContext();
|
||||
|
||||
const isRoomEncrypted = useIsEncrypted(cli, room) ?? false;
|
||||
const roomContext = useScopedRoomContext("e2eStatus", "timelineRenderingType");
|
||||
const e2eStatus = roomContext.e2eStatus;
|
||||
const isVideoRoom = calcIsVideoRoom(room);
|
||||
|
||||
const roomState = useRoomState(room);
|
||||
// used to check if the room is public or not
|
||||
const roomJoinRule = roomState.getJoinRule();
|
||||
const alias = room.getCanonicalAlias() || room.getAltAliases()[0] || "";
|
||||
const pinCount = usePinnedEvents(room).length;
|
||||
// value to check if the user can invite to the room
|
||||
const canInviteToState = useEventEmitterState(room, RoomStateEvent.Update, () => canInviteTo(room));
|
||||
|
||||
const roomTags = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () =>
|
||||
RoomListStore.instance.getTagsForRoom(room),
|
||||
);
|
||||
const isFavorite = roomTags.includes(DefaultTagID.Favourite);
|
||||
|
||||
const isDirectMessage = useIsDirectMessage(room);
|
||||
|
||||
const onRoomMembersClick = (): void => {
|
||||
RightPanelStore.instance.pushCard({ phase: RightPanelPhases.MemberList }, true);
|
||||
};
|
||||
|
||||
const onRoomThreadsClick = (): void => {
|
||||
RightPanelStore.instance.pushCard({ phase: RightPanelPhases.ThreadPanel }, true);
|
||||
};
|
||||
|
||||
const onRoomFilesClick = (): void => {
|
||||
RightPanelStore.instance.pushCard({ phase: RightPanelPhases.FilePanel }, true);
|
||||
};
|
||||
|
||||
const onRoomExtensionsClick = (): void => {
|
||||
RightPanelStore.instance.pushCard({ phase: RightPanelPhases.Extensions }, true);
|
||||
};
|
||||
|
||||
const onRoomPinsClick = (): void => {
|
||||
PosthogTrackers.trackInteraction("PinnedMessageRoomInfoButton");
|
||||
RightPanelStore.instance.pushCard({ phase: RightPanelPhases.PinnedMessages }, true);
|
||||
};
|
||||
|
||||
const onRoomSettingsClick = (ev: Event): void => {
|
||||
defaultDispatcher.dispatch({ action: "open_room_settings" });
|
||||
PosthogTrackers.trackInteraction("WebRightPanelRoomInfoSettingsButton", ev);
|
||||
};
|
||||
|
||||
const onShareRoomClick = (): void => {
|
||||
Modal.createDialog(ShareDialog, {
|
||||
target: room,
|
||||
});
|
||||
};
|
||||
|
||||
const onRoomExportClick = async (): Promise<void> => {
|
||||
Modal.createDialog(ExportDialog, {
|
||||
room,
|
||||
});
|
||||
};
|
||||
|
||||
const onRoomPollHistoryClick = (): void => {
|
||||
Modal.createDialog(PollHistoryDialog, {
|
||||
room,
|
||||
matrixClient: cli,
|
||||
permalinkCreator,
|
||||
});
|
||||
};
|
||||
|
||||
const onLeaveRoomClick = (): void => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "leave_room",
|
||||
room_id: room.roomId,
|
||||
});
|
||||
};
|
||||
|
||||
const onReportRoomClick = async (): Promise<void> => {
|
||||
const [leave] = await Modal.createDialog(ReportRoomDialog, {
|
||||
roomId: room.roomId,
|
||||
}).finished;
|
||||
if (leave) {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "leave_room",
|
||||
room_id: room.roomId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onFavoriteToggleClick = (): void => {
|
||||
tagRoom(room, DefaultTagID.Favourite);
|
||||
};
|
||||
|
||||
const onInviteToRoomClick = (): void => {
|
||||
inviteToRoom(room);
|
||||
};
|
||||
|
||||
// Room Search element ref
|
||||
const { searchInputRef, onUpdateSearchInput } = useSearchInput(onSearchCancel);
|
||||
|
||||
return {
|
||||
isDirectMessage,
|
||||
isRoomEncrypted,
|
||||
roomJoinRule,
|
||||
e2eStatus,
|
||||
isVideoRoom,
|
||||
alias,
|
||||
isFavorite,
|
||||
canInviteToState,
|
||||
searchInputRef,
|
||||
pinCount,
|
||||
onRoomMembersClick,
|
||||
onRoomThreadsClick,
|
||||
onRoomFilesClick,
|
||||
onRoomExtensionsClick,
|
||||
onRoomPinsClick,
|
||||
onRoomSettingsClick,
|
||||
onLeaveRoomClick,
|
||||
onShareRoomClick,
|
||||
onRoomExportClick,
|
||||
onRoomPollHistoryClick,
|
||||
onReportRoomClick,
|
||||
onUpdateSearchInput,
|
||||
onFavoriteToggleClick,
|
||||
onInviteToRoomClick,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
Copyright 2025 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 { type Room, type RoomMember, type IPowerLevelsContent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
|
||||
|
||||
/**
|
||||
* Interface used by admin tools container subcomponents props
|
||||
*/
|
||||
export interface RoomAdminToolsProps {
|
||||
room: Room;
|
||||
member: RoomMember;
|
||||
isUpdating: boolean;
|
||||
startUpdating: () => void;
|
||||
stopUpdating: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface used by admin tools container props
|
||||
*/
|
||||
export interface RoomAdminToolsContainerProps {
|
||||
room: Room;
|
||||
member: RoomMember;
|
||||
powerLevels: IPowerLevelsContent;
|
||||
}
|
||||
|
||||
interface UserInfoAdminToolsContainerState {
|
||||
shouldShowKickButton: boolean;
|
||||
shouldShowBanButton: boolean;
|
||||
shouldShowMuteButton: boolean;
|
||||
shouldShowRedactButton: boolean;
|
||||
isCurrentUserInTheRoom: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The view model for the user info admin tools container
|
||||
* @param {RoomAdminToolsContainerProps} props - the object containing the necceray props for the view model
|
||||
* @param {Room} props.room - the room that display the admin tools
|
||||
* @param {RoomMember} props.member - the selected member
|
||||
* @param {IPowerLevelsContent} props.powerLevels - current room power levels
|
||||
* @returns {UserInfoAdminToolsContainerState} the user info admin tools container state
|
||||
*/
|
||||
export const useUserInfoAdminToolsContainerViewModel = (
|
||||
props: RoomAdminToolsContainerProps,
|
||||
): UserInfoAdminToolsContainerState => {
|
||||
const cli = useMatrixClientContext();
|
||||
const { room, member, powerLevels } = props;
|
||||
|
||||
const editPowerLevel =
|
||||
(powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) || powerLevels.state_default;
|
||||
|
||||
// if these do not exist in the event then they should default to 50 as per the spec
|
||||
const { ban: banPowerLevel = 50, kick: kickPowerLevel = 50, redact: redactPowerLevel = 50 } = powerLevels;
|
||||
|
||||
const me = room.getMember(cli.getUserId() || "");
|
||||
const isCurrentUserInTheRoom = me !== null;
|
||||
|
||||
if (!isCurrentUserInTheRoom) {
|
||||
return {
|
||||
shouldShowKickButton: false,
|
||||
shouldShowBanButton: false,
|
||||
shouldShowMuteButton: false,
|
||||
shouldShowRedactButton: false,
|
||||
isCurrentUserInTheRoom: false,
|
||||
};
|
||||
}
|
||||
|
||||
const isMe = me.userId === member.userId;
|
||||
const canAffectUser = member.powerLevel < me.powerLevel || isMe;
|
||||
|
||||
return {
|
||||
shouldShowKickButton: !isMe && canAffectUser && me.powerLevel >= kickPowerLevel,
|
||||
shouldShowRedactButton: me.powerLevel >= redactPowerLevel && !room.isSpaceRoom(),
|
||||
shouldShowBanButton: !isMe && canAffectUser && me.powerLevel >= banPowerLevel,
|
||||
shouldShowMuteButton: !isMe && canAffectUser && me.powerLevel >= Number(editPowerLevel) && !room.isSpaceRoom(),
|
||||
isCurrentUserInTheRoom,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
Copyright 2025 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 { logger } from "@sentry/browser";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
|
||||
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
|
||||
import { _t } from "../../../../../languageHandler";
|
||||
import Modal from "../../../../../Modal";
|
||||
import { bulkSpaceBehaviour } from "../../../../../utils/space";
|
||||
import ConfirmSpaceUserActionDialog from "../../../../views/dialogs/ConfirmSpaceUserActionDialog";
|
||||
import ConfirmUserActionDialog from "../../../../views/dialogs/ConfirmUserActionDialog";
|
||||
import ErrorDialog from "../../../../views/dialogs/ErrorDialog";
|
||||
import { type RoomAdminToolsProps } from "./UserInfoAdminToolsContainerViewModel";
|
||||
|
||||
export interface BanButtonState {
|
||||
/**
|
||||
* The function to call when the button is clicked
|
||||
*/
|
||||
onBanOrUnbanClick: () => Promise<void>;
|
||||
/**
|
||||
* The label of the ban button can be ban or unban
|
||||
*/
|
||||
banLabel: string;
|
||||
}
|
||||
/**
|
||||
* The view model for the room ban button used in the UserInfoAdminToolsContainer
|
||||
* @param {RoomAdminToolsProps} props - the object containing the necceray props for banButton the view model
|
||||
* @param {Room} props.room - the room to ban/unban the user in
|
||||
* @param {RoomMember} props.member - the member to ban/unban
|
||||
* @param {boolean} props.isUpdating - whether the operation is currently in progress
|
||||
* @param {function} props.startUpdating - callback function to start the operation
|
||||
* @param {function} props.stopUpdating - callback function to stop the operation
|
||||
* @returns {BanButtonState} the room ban/unban button state
|
||||
*/
|
||||
export const useBanButtonViewModel = (props: RoomAdminToolsProps): BanButtonState => {
|
||||
const { isUpdating, startUpdating, stopUpdating, room, member } = props;
|
||||
|
||||
const cli = useMatrixClientContext();
|
||||
|
||||
const isBanned = member.membership === KnownMembership.Ban;
|
||||
|
||||
let banLabel = room.isSpaceRoom() ? _t("user_info|ban_button_space") : _t("user_info|ban_button_room");
|
||||
if (isBanned) {
|
||||
banLabel = room.isSpaceRoom() ? _t("user_info|unban_button_space") : _t("user_info|unban_button_room");
|
||||
}
|
||||
|
||||
const onBanOrUnbanClick = async (): Promise<void> => {
|
||||
if (isUpdating) return; // only allow one operation at a time
|
||||
startUpdating();
|
||||
|
||||
const commonProps = {
|
||||
member,
|
||||
action: room.isSpaceRoom()
|
||||
? isBanned
|
||||
? _t("user_info|unban_button_space")
|
||||
: _t("user_info|ban_button_space")
|
||||
: isBanned
|
||||
? _t("user_info|unban_button_room")
|
||||
: _t("user_info|ban_button_room"),
|
||||
title: isBanned
|
||||
? _t("user_info|unban_room_confirm_title", { roomName: room.name })
|
||||
: _t("user_info|ban_room_confirm_title", { roomName: room.name }),
|
||||
askReason: !isBanned,
|
||||
danger: !isBanned,
|
||||
};
|
||||
|
||||
let finished: Promise<[success?: boolean, reason?: string, rooms?: Room[]]>;
|
||||
|
||||
if (room.isSpaceRoom()) {
|
||||
({ finished } = Modal.createDialog(
|
||||
ConfirmSpaceUserActionDialog,
|
||||
{
|
||||
...commonProps,
|
||||
space: room,
|
||||
spaceChildFilter: isBanned
|
||||
? (child: Room) => {
|
||||
// Return true if the target member is banned and we have sufficient PL to unban
|
||||
const myMember = child.getMember(cli.credentials.userId || "");
|
||||
const theirMember = child.getMember(member.userId);
|
||||
return (
|
||||
!!myMember &&
|
||||
!!theirMember &&
|
||||
theirMember.membership === KnownMembership.Ban &&
|
||||
myMember.powerLevel > theirMember.powerLevel &&
|
||||
child.currentState.hasSufficientPowerLevelFor("ban", myMember.powerLevel)
|
||||
);
|
||||
}
|
||||
: (child: Room) => {
|
||||
// Return true if the target member isn't banned and we have sufficient PL to ban
|
||||
const myMember = child.getMember(cli.credentials.userId || "");
|
||||
const theirMember = child.getMember(member.userId);
|
||||
return (
|
||||
!!myMember &&
|
||||
!!theirMember &&
|
||||
theirMember.membership !== KnownMembership.Ban &&
|
||||
myMember.powerLevel > theirMember.powerLevel &&
|
||||
child.currentState.hasSufficientPowerLevelFor("ban", myMember.powerLevel)
|
||||
);
|
||||
},
|
||||
allLabel: isBanned ? _t("user_info|unban_space_everything") : _t("user_info|ban_space_everything"),
|
||||
specificLabel: isBanned ? _t("user_info|unban_space_specific") : _t("user_info|ban_space_specific"),
|
||||
warningMessage: isBanned ? _t("user_info|unban_space_warning") : _t("user_info|kick_space_warning"),
|
||||
},
|
||||
"mx_ConfirmSpaceUserActionDialog_wrapper",
|
||||
));
|
||||
} else {
|
||||
({ finished } = Modal.createDialog(ConfirmUserActionDialog, commonProps));
|
||||
}
|
||||
|
||||
const [proceed, reason, rooms = []] = await finished;
|
||||
if (!proceed) {
|
||||
stopUpdating();
|
||||
return;
|
||||
}
|
||||
|
||||
const fn = (roomId: string): Promise<unknown> => {
|
||||
if (isBanned) {
|
||||
return cli.unban(roomId, member.userId);
|
||||
} else {
|
||||
return cli.ban(roomId, member.userId, reason || undefined);
|
||||
}
|
||||
};
|
||||
|
||||
bulkSpaceBehaviour(room, rooms, (room) => fn(room.roomId))
|
||||
.then(
|
||||
() => {
|
||||
// NO-OP; rely on the m.room.member event coming down else we could
|
||||
// get out of sync if we force setState here!
|
||||
logger.info("Ban success");
|
||||
},
|
||||
function (err) {
|
||||
logger.error("Ban error: " + err);
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("common|error"),
|
||||
description: _t("user_info|error_ban_user"),
|
||||
});
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
stopUpdating();
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
onBanOrUnbanClick,
|
||||
banLabel,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
Copyright 2025 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 { logger } from "@sentry/browser";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
|
||||
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
|
||||
import { _t } from "../../../../../languageHandler";
|
||||
import Modal from "../../../../../Modal";
|
||||
import { bulkSpaceBehaviour } from "../../../../../utils/space";
|
||||
import ConfirmSpaceUserActionDialog from "../../../../views/dialogs/ConfirmSpaceUserActionDialog";
|
||||
import ConfirmUserActionDialog from "../../../../views/dialogs/ConfirmUserActionDialog";
|
||||
import ErrorDialog from "../../../../views/dialogs/ErrorDialog";
|
||||
import { type RoomAdminToolsProps } from "./UserInfoAdminToolsContainerViewModel";
|
||||
|
||||
interface RoomKickButtonState {
|
||||
/**
|
||||
* The function to call when the button is clicked
|
||||
*/
|
||||
onKickClick: () => Promise<void>;
|
||||
/**
|
||||
* Whether the user can be kicked based on membership value. If the user already join or was invited, it can be kicked
|
||||
*/
|
||||
canUserBeKicked: boolean;
|
||||
/**
|
||||
* The label of the kick button can be kick or disinvite
|
||||
*/
|
||||
kickLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The view model for the room kick button used in the UserInfoAdminToolsContainer
|
||||
* @param {RoomAdminToolsProps} props - the object containing the necceray props for kickButton the view model
|
||||
* @param {Room} props.room - the room to kick/disinvite the user from
|
||||
* @param {RoomMember} props.member - the member to kick/disinvite
|
||||
* @param {boolean} props.isUpdating - whether the operation is currently in progress
|
||||
* @param {function} props.startUpdating - callback function to start the operation
|
||||
* @param {function} props.stopUpdating - callback function to stop the operation
|
||||
* @returns {KickButtonState} the room kick/disinvite button state
|
||||
*/
|
||||
export function useRoomKickButtonViewModel(props: RoomAdminToolsProps): RoomKickButtonState {
|
||||
const { isUpdating, startUpdating, stopUpdating, room, member } = props;
|
||||
|
||||
const cli = useMatrixClientContext();
|
||||
|
||||
const onKickClick = async (): Promise<void> => {
|
||||
if (isUpdating) return; // only allow one operation at a time
|
||||
startUpdating();
|
||||
|
||||
const commonProps = {
|
||||
member,
|
||||
action: room.isSpaceRoom()
|
||||
? member.membership === KnownMembership.Invite
|
||||
? _t("user_info|disinvite_button_space")
|
||||
: _t("user_info|kick_button_space")
|
||||
: member.membership === KnownMembership.Invite
|
||||
? _t("user_info|disinvite_button_room")
|
||||
: _t("user_info|kick_button_room"),
|
||||
title:
|
||||
member.membership === KnownMembership.Invite
|
||||
? _t("user_info|disinvite_button_room_name", { roomName: room.name })
|
||||
: _t("user_info|kick_button_room_name", { roomName: room.name }),
|
||||
askReason: member.membership === KnownMembership.Join,
|
||||
danger: true,
|
||||
};
|
||||
|
||||
let finished: Promise<[success?: boolean, reason?: string, rooms?: Room[]]>;
|
||||
|
||||
if (room.isSpaceRoom()) {
|
||||
({ finished } = Modal.createDialog(
|
||||
ConfirmSpaceUserActionDialog,
|
||||
{
|
||||
...commonProps,
|
||||
space: room,
|
||||
spaceChildFilter: (child: Room) => {
|
||||
// Return true if the target member is not banned and we have sufficient PL to ban them
|
||||
const myMember = child.getMember(cli.credentials.userId || "");
|
||||
const theirMember = child.getMember(member.userId);
|
||||
return (
|
||||
!!myMember &&
|
||||
!!theirMember &&
|
||||
theirMember.membership === member.membership &&
|
||||
myMember.powerLevel > theirMember.powerLevel &&
|
||||
child.currentState.hasSufficientPowerLevelFor("kick", myMember.powerLevel)
|
||||
);
|
||||
},
|
||||
allLabel: _t("user_info|kick_button_space_everything"),
|
||||
specificLabel: _t("user_info|kick_space_specific"),
|
||||
warningMessage: _t("user_info|kick_space_warning"),
|
||||
},
|
||||
"mx_ConfirmSpaceUserActionDialog_wrapper",
|
||||
));
|
||||
} else {
|
||||
({ finished } = Modal.createDialog(ConfirmUserActionDialog, commonProps));
|
||||
}
|
||||
|
||||
const [proceed, reason, rooms = []] = await finished;
|
||||
if (!proceed) {
|
||||
stopUpdating();
|
||||
return;
|
||||
}
|
||||
|
||||
bulkSpaceBehaviour(room, rooms, (room) => cli.kick(room.roomId, member.userId, reason || undefined))
|
||||
.then(
|
||||
() => {
|
||||
// NO-OP; rely on the m.room.member event coming down else we could
|
||||
// get out of sync if we force setState here!
|
||||
logger.info("Kick success");
|
||||
},
|
||||
function (err) {
|
||||
logger.error("Kick error: " + err);
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("user_info|error_kicking_user"),
|
||||
description: err?.message ?? "Operation failed",
|
||||
});
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
stopUpdating();
|
||||
});
|
||||
};
|
||||
|
||||
const canUserBeKicked = member.membership === KnownMembership.Invite || member.membership === KnownMembership.Join;
|
||||
|
||||
const kickLabel = room.isSpaceRoom()
|
||||
? member.membership === KnownMembership.Invite
|
||||
? _t("user_info|disinvite_button_space")
|
||||
: _t("user_info|kick_button_space")
|
||||
: member.membership === KnownMembership.Invite
|
||||
? _t("user_info|disinvite_button_room")
|
||||
: _t("user_info|kick_button_room");
|
||||
|
||||
return {
|
||||
onKickClick,
|
||||
canUserBeKicked,
|
||||
kickLabel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
Copyright 2025 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 { logger } from "@sentry/browser";
|
||||
import { type RoomMember, type IPowerLevelsContent } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
|
||||
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
|
||||
import { _t } from "../../../../../languageHandler";
|
||||
import Modal from "../../../../../Modal";
|
||||
import ErrorDialog from "../../../../views/dialogs/ErrorDialog";
|
||||
import { type RoomAdminToolsProps } from "./UserInfoAdminToolsContainerViewModel";
|
||||
|
||||
interface MuteButtonState {
|
||||
/**
|
||||
* Whether the member is in the roomn based on the membership value
|
||||
*/
|
||||
isMemberInTheRoom: boolean;
|
||||
/**
|
||||
* The label of the mute button can be mute or unmute
|
||||
*/
|
||||
muteLabel: string;
|
||||
/**
|
||||
* The function to call when the mute button is clicked
|
||||
*/
|
||||
onMuteButtonClick: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The view model for the room mute button used in the UserInfoAdminToolsContainer
|
||||
* @param {RoomAdminToolsProps} props - the object containing the necceray props for muteButton the view model
|
||||
* @param {Room} props.room - the room to mute/unmute the user in
|
||||
* @param {RoomMember} props.member - the member to mute/unmute
|
||||
* @param {boolean} props.isUpdating - whether the operation is currently in progress
|
||||
* @param {function} props.startUpdating - callback function to start the operation
|
||||
* @param {function} props.stopUpdating - callback function to stop the operation
|
||||
* @returns {MuteButtonState} the room mute/unmute button state
|
||||
*/
|
||||
export const useMuteButtonViewModel = (props: RoomAdminToolsProps): MuteButtonState => {
|
||||
const { isUpdating, startUpdating, stopUpdating, room, member } = props;
|
||||
|
||||
const cli = useMatrixClientContext();
|
||||
|
||||
const isMuted = (member: RoomMember, powerLevelContent: IPowerLevelsContent): boolean => {
|
||||
if (!powerLevelContent || !member) return false;
|
||||
|
||||
const levelToSend =
|
||||
(powerLevelContent.events ? powerLevelContent.events["m.room.message"] : null) ||
|
||||
powerLevelContent.events_default;
|
||||
|
||||
// levelToSend could be undefined as .events_default is optional. Coercing in this case using
|
||||
// Number() would always return false, so this preserves behaviour
|
||||
// FIXME: per the spec, if `events_default` is unset, it defaults to zero. If
|
||||
// the member has a negative powerlevel, this will give an incorrect result.
|
||||
if (levelToSend === undefined) return false;
|
||||
|
||||
return member.powerLevel < levelToSend;
|
||||
};
|
||||
|
||||
const muted = isMuted(member, room.currentState.getStateEvents("m.room.power_levels", "")?.getContent() || {});
|
||||
const muteLabel = muted ? _t("common|unmute") : _t("common|mute");
|
||||
|
||||
const isMemberInTheRoom = member.membership == KnownMembership.Join;
|
||||
|
||||
const onMuteButtonClick = async (): Promise<void> => {
|
||||
if (isUpdating) return; // only allow one operation at a time
|
||||
startUpdating();
|
||||
|
||||
const roomId = member.roomId;
|
||||
const target = member.userId;
|
||||
|
||||
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
|
||||
const powerLevels = powerLevelEvent?.getContent();
|
||||
const levelToSend = powerLevels?.events?.["m.room.message"] ?? powerLevels?.events_default;
|
||||
|
||||
let level;
|
||||
if (muted) {
|
||||
// unmute
|
||||
level = levelToSend;
|
||||
} else {
|
||||
// mute
|
||||
level = levelToSend - 1;
|
||||
}
|
||||
level = parseInt(level);
|
||||
|
||||
console.log("level", level);
|
||||
if (isNaN(level)) {
|
||||
stopUpdating();
|
||||
return;
|
||||
}
|
||||
|
||||
cli.setPowerLevel(roomId, target, level)
|
||||
.then(
|
||||
() => {
|
||||
// NO-OP; rely on the m.room.member event coming down else we could
|
||||
// get out of sync if we force setState here!
|
||||
logger.info("Mute toggle success");
|
||||
},
|
||||
function (err) {
|
||||
logger.error("Mute error: " + err);
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("common|error"),
|
||||
description: _t("user_info|error_mute_user"),
|
||||
});
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
stopUpdating();
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
isMemberInTheRoom,
|
||||
onMuteButtonClick,
|
||||
muteLabel,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Copyright 2025 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 { type RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
|
||||
import Modal from "../../../../../Modal";
|
||||
import BulkRedactDialog from "../../../../views/dialogs/BulkRedactDialog";
|
||||
|
||||
export interface RedactMessagesButtonState {
|
||||
onRedactAllMessagesClick: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The view model for the redact messages button used in the UserInfoAdminToolsContainer
|
||||
* @param {RoomMember} member - the selected member to redact messages for
|
||||
* @returns {RedactMessagesButtonState} the redact messages button state
|
||||
*/
|
||||
export const useRedactMessagesButtonViewModel = (member: RoomMember): RedactMessagesButtonState => {
|
||||
const cli = useMatrixClientContext();
|
||||
|
||||
const onRedactAllMessagesClick = (): void => {
|
||||
const room = cli.getRoom(member.roomId);
|
||||
if (!room) return;
|
||||
|
||||
Modal.createDialog(BulkRedactDialog, {
|
||||
matrixClient: cli,
|
||||
room,
|
||||
member,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
onRedactAllMessagesClick,
|
||||
};
|
||||
};
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { createRoom, hasCreateRoomRights } from "./utils";
|
||||
import { type SortOption, useSorter } from "./useSorter";
|
||||
|
||||
/**
|
||||
* Hook to get the active space and its title.
|
||||
@@ -117,6 +118,14 @@ export interface RoomListHeaderViewState {
|
||||
* Open the space settings
|
||||
*/
|
||||
openSpaceSettings: () => void;
|
||||
/**
|
||||
* Change the sort order of the room-list.
|
||||
*/
|
||||
sort: (option: SortOption) => void;
|
||||
/**
|
||||
* The currently active sort option.
|
||||
*/
|
||||
activeSortOption: SortOption;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,6 +147,8 @@ export function useRoomListHeaderViewModel(): RoomListHeaderViewState {
|
||||
|
||||
/* Actions */
|
||||
|
||||
const { activeSortOption, sort } = useSorter();
|
||||
|
||||
const createChatRoom = useCallback((e: Event) => {
|
||||
defaultDispatcher.fire(Action.CreateChat);
|
||||
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateChatItem", e);
|
||||
@@ -207,5 +218,7 @@ export function useRoomListHeaderViewModel(): RoomListHeaderViewState {
|
||||
inviteInSpace,
|
||||
openSpacePreferences,
|
||||
openSpaceSettings,
|
||||
activeSortOption,
|
||||
sort,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ export interface RoomListItemMenuViewState {
|
||||
* Whether the room is a favourite room.
|
||||
*/
|
||||
isFavourite: boolean;
|
||||
/**
|
||||
* Whether the room is a low priority room.
|
||||
*/
|
||||
isLowPriority: boolean;
|
||||
/**
|
||||
* Can invite other user's in the room.
|
||||
*/
|
||||
@@ -117,6 +121,7 @@ export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewSt
|
||||
|
||||
const isDm = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId));
|
||||
const isFavourite = Boolean(roomTags[DefaultTagID.Favourite]);
|
||||
const isLowPriority = Boolean(roomTags[DefaultTagID.LowPriority]);
|
||||
const isArchived = Boolean(roomTags[DefaultTagID.Archived]);
|
||||
|
||||
const showMoreOptionsMenu = hasAccessToOptionsMenu(room);
|
||||
@@ -200,6 +205,7 @@ export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewSt
|
||||
showMoreOptionsMenu,
|
||||
showNotificationMenu,
|
||||
isFavourite,
|
||||
isLowPriority,
|
||||
canInvite,
|
||||
canCopyRoomLink,
|
||||
canMarkAsRead,
|
||||
|
||||
@@ -8,9 +8,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type { Room } from "matrix-js-sdk/src/matrix";
|
||||
import { type PrimaryFilter, type SecondaryFilters, useFilteredRooms } from "./useFilteredRooms";
|
||||
import { type SortOption, useSorter } from "./useSorter";
|
||||
import { useMessagePreviewToggle } from "./useMessagePreviewToggle";
|
||||
import { type PrimaryFilter, useFilteredRooms } from "./useFilteredRooms";
|
||||
import { createRoom as createRoomFunc, hasCreateRoomRights } from "./utils";
|
||||
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||
import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces";
|
||||
@@ -61,36 +59,6 @@ export interface RoomListViewState {
|
||||
*/
|
||||
activePrimaryFilter?: PrimaryFilter;
|
||||
|
||||
/**
|
||||
* A function to activate a given secondary filter.
|
||||
*/
|
||||
activateSecondaryFilter: (filter: SecondaryFilters) => void;
|
||||
|
||||
/**
|
||||
* The currently active secondary filter.
|
||||
*/
|
||||
activeSecondaryFilter: SecondaryFilters;
|
||||
|
||||
/**
|
||||
* Change the sort order of the room-list.
|
||||
*/
|
||||
sort: (option: SortOption) => void;
|
||||
|
||||
/**
|
||||
* The currently active sort option.
|
||||
*/
|
||||
activeSortOption: SortOption;
|
||||
|
||||
/**
|
||||
* Whether message previews must be shown or not.
|
||||
*/
|
||||
shouldShowMessagePreview: boolean;
|
||||
|
||||
/**
|
||||
* A function to turn on/off message previews.
|
||||
*/
|
||||
toggleMessagePreview: () => void;
|
||||
|
||||
/**
|
||||
* The index of the active room in the room list.
|
||||
*/
|
||||
@@ -103,14 +71,7 @@ export interface RoomListViewState {
|
||||
*/
|
||||
export function useRoomListViewModel(): RoomListViewState {
|
||||
const matrixClient = useMatrixClientContext();
|
||||
const {
|
||||
isLoadingRooms,
|
||||
primaryFilters,
|
||||
activePrimaryFilter,
|
||||
rooms: filteredRooms,
|
||||
activateSecondaryFilter,
|
||||
activeSecondaryFilter,
|
||||
} = useFilteredRooms();
|
||||
const { isLoadingRooms, primaryFilters, activePrimaryFilter, rooms: filteredRooms } = useFilteredRooms();
|
||||
const { activeIndex, rooms } = useStickyRoomList(filteredRooms);
|
||||
|
||||
useRoomListNavigation(rooms);
|
||||
@@ -122,9 +83,6 @@ export function useRoomListViewModel(): RoomListViewState {
|
||||
);
|
||||
const canCreateRoom = hasCreateRoomRights(matrixClient, currentSpace);
|
||||
|
||||
const { activeSortOption, sort } = useSorter();
|
||||
const { shouldShowMessagePreview, toggleMessagePreview } = useMessagePreviewToggle();
|
||||
|
||||
const createChatRoom = useCallback(() => dispatcher.fire(Action.CreateChat), []);
|
||||
const createRoom = useCallback(() => createRoomFunc(currentSpace), [currentSpace]);
|
||||
|
||||
@@ -136,12 +94,6 @@ export function useRoomListViewModel(): RoomListViewState {
|
||||
createChatRoom,
|
||||
primaryFilters,
|
||||
activePrimaryFilter,
|
||||
activateSecondaryFilter,
|
||||
activeSecondaryFilter,
|
||||
activeSortOption,
|
||||
sort,
|
||||
shouldShowMessagePreview,
|
||||
toggleMessagePreview,
|
||||
activeIndex,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@ interface FilteredRooms {
|
||||
primaryFilters: PrimaryFilter[];
|
||||
isLoadingRooms: boolean;
|
||||
rooms: Room[];
|
||||
activateSecondaryFilter: (filter: SecondaryFilters) => void;
|
||||
activeSecondaryFilter: SecondaryFilters;
|
||||
/**
|
||||
* The currently active primary filter.
|
||||
* If no primary filter is active, this will be undefined.
|
||||
@@ -47,53 +45,14 @@ interface FilteredRooms {
|
||||
|
||||
const filterKeyToNameMap: Map<FilterKey, TranslationKey> = new Map([
|
||||
[FilterKey.UnreadFilter, _td("room_list|filters|unread")],
|
||||
[FilterKey.FavouriteFilter, _td("room_list|filters|favourite")],
|
||||
[FilterKey.PeopleFilter, _td("room_list|filters|people")],
|
||||
[FilterKey.RoomsFilter, _td("room_list|filters|rooms")],
|
||||
[FilterKey.MentionsFilter, _td("room_list|filters|mentions")],
|
||||
[FilterKey.InvitesFilter, _td("room_list|filters|invites")],
|
||||
[FilterKey.FavouriteFilter, _td("room_list|filters|favourite")],
|
||||
[FilterKey.LowPriorityFilter, _td("room_list|filters|low_priority")],
|
||||
]);
|
||||
|
||||
/**
|
||||
* These are the secondary filters which are not prominently shown
|
||||
* in the UI.
|
||||
*/
|
||||
export const enum SecondaryFilters {
|
||||
AllActivity,
|
||||
MentionsOnly,
|
||||
InvitesOnly,
|
||||
LowPriority,
|
||||
}
|
||||
|
||||
/**
|
||||
* A map from {@link SecondaryFilters} which the UI understands to
|
||||
* {@link FilterKey} which the store understands.
|
||||
*/
|
||||
const secondaryFiltersToFilterKeyMap = new Map([
|
||||
[SecondaryFilters.AllActivity, undefined],
|
||||
[SecondaryFilters.MentionsOnly, FilterKey.MentionsFilter],
|
||||
[SecondaryFilters.InvitesOnly, FilterKey.InvitesFilter],
|
||||
[SecondaryFilters.LowPriority, FilterKey.LowPriorityFilter],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Use this function to determine if a given primary filter is compatible with
|
||||
* a given secondary filter. Practically, this determines whether it makes sense
|
||||
* to expose two filters together in the UI - for eg, it does not make sense to show the
|
||||
* favourite primary filter if the active secondary filter is low priority.
|
||||
* @param primary Primary filter key
|
||||
* @param secondary Secondary filter key
|
||||
* @returns true if compatible, false otherwise
|
||||
*/
|
||||
function isPrimaryFilterCompatible(primary: FilterKey, secondary: FilterKey): boolean {
|
||||
if (secondary === FilterKey.MentionsFilter) {
|
||||
if (primary === FilterKey.UnreadFilter) return false;
|
||||
} else if (secondary === FilterKey.InvitesFilter) {
|
||||
if (primary === FilterKey.UnreadFilter || primary === FilterKey.FavouriteFilter) return false;
|
||||
} else if (secondary === FilterKey.LowPriorityFilter) {
|
||||
if (primary === FilterKey.FavouriteFilter) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track available filters and provide a filtered list of rooms.
|
||||
*/
|
||||
@@ -103,16 +62,6 @@ export function useFilteredRooms(): FilteredRooms {
|
||||
* rendered above the room list.
|
||||
*/
|
||||
const [primaryFilter, setPrimaryFilter] = useState<FilterKey | undefined>();
|
||||
/**
|
||||
* Secondary filters are also filters but they are hidden
|
||||
* away in a popup menu.
|
||||
*/
|
||||
const [activeSecondaryFilter, setActiveSecondaryFilter] = useState<SecondaryFilters>(SecondaryFilters.AllActivity);
|
||||
|
||||
const secondaryFilter = useMemo(
|
||||
() => secondaryFiltersToFilterKeyMap.get(activeSecondaryFilter),
|
||||
[activeSecondaryFilter],
|
||||
);
|
||||
|
||||
const [rooms, setRooms] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace());
|
||||
const [isLoadingRooms, setIsLoadingRooms] = useState(() => RoomListStoreV3.instance.isLoadingRooms);
|
||||
@@ -123,16 +72,13 @@ export function useFilteredRooms(): FilteredRooms {
|
||||
}, []);
|
||||
|
||||
// Reset filters when active space changes
|
||||
useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
|
||||
setPrimaryFilter(undefined);
|
||||
activateSecondaryFilter(SecondaryFilters.AllActivity);
|
||||
});
|
||||
useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => setPrimaryFilter(undefined));
|
||||
|
||||
const filterUndefined = (array: (FilterKey | undefined)[]): FilterKey[] =>
|
||||
array.filter((f) => f !== undefined) as FilterKey[];
|
||||
|
||||
const getAppliedFilters = (): FilterKey[] => {
|
||||
return filterUndefined([primaryFilter, secondaryFilter]);
|
||||
return filterUndefined([primaryFilter]);
|
||||
};
|
||||
|
||||
useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => {
|
||||
@@ -144,30 +90,6 @@ export function useFilteredRooms(): FilteredRooms {
|
||||
setIsLoadingRooms(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Secondary filters are activated using this function.
|
||||
* This is different to how primary filters work because the secondary
|
||||
* filters are static i.e they are always available and don't need to be
|
||||
* hidden.
|
||||
*/
|
||||
const activateSecondaryFilter = useCallback(
|
||||
(filter: SecondaryFilters): void => {
|
||||
// If the filter is already active, just return.
|
||||
if (filter === activeSecondaryFilter) return;
|
||||
|
||||
// SecondaryFilter is an enum for the UI, let's convert it to something
|
||||
// that the store will understand.
|
||||
const secondary = secondaryFiltersToFilterKeyMap.get(filter);
|
||||
setActiveSecondaryFilter(filter);
|
||||
|
||||
// Reset any active primary filters.
|
||||
setPrimaryFilter(undefined);
|
||||
|
||||
updateRoomsFromStore(filterUndefined([secondary]));
|
||||
},
|
||||
[activeSecondaryFilter, updateRoomsFromStore],
|
||||
);
|
||||
|
||||
/**
|
||||
* This tells the view which primary filters are available, how to toggle them
|
||||
* and whether a given primary filter is active. @see {@link PrimaryFilter}
|
||||
@@ -178,7 +100,7 @@ export function useFilteredRooms(): FilteredRooms {
|
||||
toggle: () => {
|
||||
setPrimaryFilter((currentFilter) => {
|
||||
const filter = currentFilter === key ? undefined : key;
|
||||
updateRoomsFromStore(filterUndefined([filter, secondaryFilter]));
|
||||
updateRoomsFromStore(filterUndefined([filter]));
|
||||
return filter;
|
||||
});
|
||||
},
|
||||
@@ -189,13 +111,10 @@ export function useFilteredRooms(): FilteredRooms {
|
||||
};
|
||||
const filters: PrimaryFilter[] = [];
|
||||
for (const [key, name] of filterKeyToNameMap.entries()) {
|
||||
if (secondaryFilter && !isPrimaryFilterCompatible(key, secondaryFilter)) {
|
||||
continue;
|
||||
}
|
||||
filters.push(createPrimaryFilter(key, _t(name)));
|
||||
}
|
||||
return filters;
|
||||
}, [primaryFilter, updateRoomsFromStore, secondaryFilter]);
|
||||
}, [primaryFilter, updateRoomsFromStore]);
|
||||
|
||||
const activePrimaryFilter = useMemo(() => primaryFilters.find((filter) => filter.active), [primaryFilters]);
|
||||
|
||||
@@ -204,7 +123,5 @@ export function useFilteredRooms(): FilteredRooms {
|
||||
primaryFilters,
|
||||
activePrimaryFilter,
|
||||
rooms,
|
||||
activateSecondaryFilter,
|
||||
activeSecondaryFilter,
|
||||
};
|
||||
}
|
||||
|
||||