Compare commits
65 Commits
7a01cdae0a
...
f7e6cb6129
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7e6cb6129 | ||
|
|
9dc9b169ab | ||
|
|
dae90a059f | ||
|
|
2f727430e1 | ||
|
|
4392aa1ed0 | ||
|
|
a721c5f4ea | ||
|
|
79f1176b92 | ||
|
|
92bb15fbba | ||
|
|
7aa7793640 | ||
|
|
f282be05ca | ||
|
|
744922cbcc | ||
|
|
7183d91930 | ||
|
|
cdedcc0b5a | ||
|
|
b679693702 | ||
|
|
fbb43d5e61 | ||
|
|
a79f6e7aa5 | ||
|
|
81c375007e | ||
|
|
aee24be1b4 | ||
|
|
1285b73be6 | ||
|
|
c203f02731 | ||
|
|
64130a018b | ||
|
|
e2fc1574bf | ||
|
|
de0492b786 | ||
|
|
0a46edaaff | ||
|
|
dd89cee328 | ||
|
|
29ff8a6199 | ||
|
|
184e6e3f29 | ||
|
|
5de9d5d24f | ||
|
|
0eff1caab2 | ||
|
|
b7acbe65c1 | ||
|
|
5736635a65 | ||
|
|
fcd23b48e0 | ||
|
|
250d6571fe | ||
|
|
f3a880f1c3 | ||
|
|
3d683ec5c6 | ||
|
|
81f1841aea | ||
|
|
e62125e61f | ||
|
|
c675453d72 | ||
|
|
ac0a91be9e | ||
|
|
333bec33ee | ||
|
|
f400d8db0a | ||
|
|
bb9c9982ef | ||
|
|
e2fd873f5e | ||
|
|
ac255445d1 | ||
|
|
425bc64aa9 | ||
|
|
9c6aa6942c | ||
|
|
5d66f9bd1a | ||
|
|
b894f8d65f | ||
|
|
6a1f0a7d22 | ||
|
|
bb582fa8f3 | ||
|
|
11b2ecb041 | ||
|
|
2ce59df1fe | ||
|
|
d85e5fca8d | ||
|
|
219a390025 | ||
|
|
2464178164 | ||
|
|
f96bfe9e18 | ||
|
|
ce529be5f4 | ||
|
|
c2c873520c | ||
|
|
36557d7383 | ||
|
|
03da342a4e | ||
|
|
caf7451862 | ||
|
|
5d17207a32 | ||
|
|
0cd108a3b4 | ||
|
|
4a9d065260 | ||
|
|
e883b05206 |
@@ -167,6 +167,10 @@ module.exports = {
|
||||
group: ["@vector-im/compound-design-tokens/icons/*"],
|
||||
message: "Please use @vector-im/compound-design-tokens/assets/web/icons/* instead",
|
||||
},
|
||||
{
|
||||
group: ["**/packages/shared-components/**", "../packages/shared-components/**"],
|
||||
message: "Please use @element-hq/web-shared-components",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
4
.github/workflows/docker.yaml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
images: |
|
||||
|
||||
2
.github/workflows/docs.yml
vendored
@@ -43,7 +43,7 @@ jobs:
|
||||
working-directory: element-web
|
||||
run: |
|
||||
yarn install --frozen-lockfile
|
||||
yarn ts-node ./scripts/gen-workflow-mermaid.ts ../element-desktop ../element-web ../matrix-js-sdk > docs/automations.md
|
||||
yarn node ./scripts/gen-workflow-mermaid.ts ../element-desktop ../element-web ../matrix-js-sdk > docs/automations.md
|
||||
echo "- [Automations](automations.md)" >> docs/SUMMARY.md
|
||||
|
||||
- name: Setup mdBook
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
name: Move PRs asking for design review to the design board
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: octokit/graphql-action@8ad880e4d437783ea2ab17010324de1075228110 # v2.3.2
|
||||
- uses: octokit/graphql-action@abaeca7ba4f0325d63b8de7ef943c2418d161b93 # v3.0.0
|
||||
id: find_team_members
|
||||
with:
|
||||
headers: '{"GraphQL-Features": "projects_next_graphql"}'
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
fi
|
||||
env:
|
||||
TEAM: "design"
|
||||
- uses: octokit/graphql-action@8ad880e4d437783ea2ab17010324de1075228110 # v2.3.2
|
||||
- uses: octokit/graphql-action@abaeca7ba4f0325d63b8de7ef943c2418d161b93 # v3.0.0
|
||||
id: add_to_project
|
||||
if: steps.any_matching_reviewers.outputs.match == 'true'
|
||||
with:
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
name: Move PRs asking for design review to the design board
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: octokit/graphql-action@8ad880e4d437783ea2ab17010324de1075228110 # v2.3.2
|
||||
- uses: octokit/graphql-action@abaeca7ba4f0325d63b8de7ef943c2418d161b93 # v3.0.0
|
||||
id: find_team_members
|
||||
with:
|
||||
headers: '{"GraphQL-Features": "projects_next_graphql"}'
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
fi
|
||||
env:
|
||||
TEAM: "product"
|
||||
- uses: octokit/graphql-action@8ad880e4d437783ea2ab17010324de1075228110 # v2.3.2
|
||||
- uses: octokit/graphql-action@abaeca7ba4f0325d63b8de7ef943c2418d161b93 # v3.0.0
|
||||
id: add_to_project
|
||||
if: steps.any_matching_reviewers.outputs.match == 'true'
|
||||
with:
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
/dist
|
||||
/lib
|
||||
/node_modules
|
||||
/packages/
|
||||
/webapp
|
||||
/*.log
|
||||
yarn.lock
|
||||
|
||||
28
CHANGELOG.md
@@ -1,3 +1,31 @@
|
||||
Changes in [1.12.5](https://github.com/element-hq/element-web/releases/tag/v1.12.5) (2025-12-02)
|
||||
================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Update Emojibase to v17 ([#31307](https://github.com/element-hq/element-web/pull/31307)). Contributed by @t3chguy.
|
||||
* Adds tooltip for compose menu ([#31122](https://github.com/element-hq/element-web/pull/31122)). Contributed by @byteplow.
|
||||
* Add option to hide pinned message banner in room view ([#31296](https://github.com/element-hq/element-web/pull/31296)). Contributed by @florianduros.
|
||||
* update twemoji to not monochromise emoji with BLACK in their name ([#31281](https://github.com/element-hq/element-web/pull/31281)). Contributed by @ara4n.
|
||||
* upgrade to twemoji 17.0.2 and fix #14695 ([#31267](https://github.com/element-hq/element-web/pull/31267)). Contributed by @ara4n.
|
||||
* Add options to hide right panel in room view ([#31252](https://github.com/element-hq/element-web/pull/31252)). Contributed by @florianduros.
|
||||
* Delayed event management: split endpoints, no auth ([#31183](https://github.com/element-hq/element-web/pull/31183)). Contributed by @AndrewFerr.
|
||||
* Support using Element Call for voice calls in DMs ([#30817](https://github.com/element-hq/element-web/pull/30817)). Contributed by @Half-Shot.
|
||||
* Improve screen reader accessibility of auth pages ([#31236](https://github.com/element-hq/element-web/pull/31236)). Contributed by @t3chguy.
|
||||
* Add posthog tracking for key backup toasts ([#31195](https://github.com/element-hq/element-web/pull/31195)). Contributed by @Half-Shot.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Return to using Fira Code as the default monospace font ([#31302](https://github.com/element-hq/element-web/pull/31302)). Contributed by @ara4n.
|
||||
* Fix case of home screen being displayed erroneously ([#31301](https://github.com/element-hq/element-web/pull/31301)). Contributed by @langleyd.
|
||||
* Fix message edition and reply when multiple rooms at displayed the same moment ([#31280](https://github.com/element-hq/element-web/pull/31280)). Contributed by @florianduros.
|
||||
* Key storage out of sync: reset key backup when needed ([#31279](https://github.com/element-hq/element-web/pull/31279)). Contributed by @uhoreg.
|
||||
* Fix invalid events crashing entire room rather than just their tile ([#31256](https://github.com/element-hq/element-web/pull/31256)). Contributed by @t3chguy.
|
||||
* Fix expand button of space panel getting cut off at the edges ([#31259](https://github.com/element-hq/element-web/pull/31259)). Contributed by @MidhunSureshR.
|
||||
* Fix pill buttons in dialogs ([#31246](https://github.com/element-hq/element-web/pull/31246)). Contributed by @dbkr.
|
||||
* Fix blank sections at the top and bottom of the member list when scrolling ([#31198](https://github.com/element-hq/element-web/pull/31198)). Contributed by @langleyd.
|
||||
* Fix emoji category selection with keyboard ([#31162](https://github.com/element-hq/element-web/pull/31162)). Contributed by @langleyd.
|
||||
|
||||
|
||||
Changes in [1.12.4](https://github.com/element-hq/element-web/releases/tag/v1.12.4) (2025-11-18)
|
||||
================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# syntax=docker.io/docker/dockerfile:1.19-labs@sha256:dce1c693ef318bca08c964ba3122ae6248e45a1b96d65c4563c8dc6fe80349a2
|
||||
|
||||
# Builder
|
||||
FROM --platform=$BUILDPLATFORM node:24-bullseye@sha256:c102f42d665c164b4e5e5549813b1547ac8a9f1d343c7d17ddac106905a1c30b AS builder
|
||||
FROM --platform=$BUILDPLATFORM node:24-bullseye@sha256:de951ccb5f52277af681a421e3328760fc4d22fbf20c391d78ef85af58430df6 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:65a7f97c299b919190e96e38e2ff8358132732000d3bc5c00c07cc8763fca53f
|
||||
FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:e2b324ae5571b5ea49ddeb03c966b240f43e5ecbdf73adcd528b49399fe11ad6
|
||||
|
||||
# Need root user to install packages & manipulate the usr directory
|
||||
USER root
|
||||
|
||||
2
knip.ts
@@ -5,6 +5,7 @@ export default {
|
||||
"src/serviceworker/index.ts",
|
||||
"src/workers/*.worker.ts",
|
||||
"src/utils/exportUtils/exportJS.js",
|
||||
"src/vector/localstorage-fix.ts",
|
||||
"scripts/**",
|
||||
"playwright/**",
|
||||
"test/**",
|
||||
@@ -55,7 +56,6 @@ export default {
|
||||
ignoreBinaries: [
|
||||
// Used in scripts & workflows
|
||||
"jq",
|
||||
"wait-on",
|
||||
],
|
||||
ignoreExportsUsedInFile: true,
|
||||
} satisfies KnipConfig;
|
||||
|
||||
@@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import * as YAML from "yaml";
|
||||
import * as fs from "fs";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
export type BuildConfig = {
|
||||
// Dev note: make everything here optional for user safety. Invalid
|
||||
|
||||
@@ -5,11 +5,11 @@ 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 * as fs from "fs";
|
||||
import * as childProcess from "child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as childProcess from "node:child_process";
|
||||
import * as semver from "semver";
|
||||
|
||||
import { type BuildConfig } from "./BuildConfig";
|
||||
import { type BuildConfig } from "./BuildConfig.ts";
|
||||
|
||||
// This expects to be run from ./scripts/install.ts
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { readBuildConfig } from "../BuildConfig";
|
||||
import { installer } from "../installer";
|
||||
import { readBuildConfig } from "../BuildConfig.ts";
|
||||
import { installer } from "../installer.ts";
|
||||
|
||||
const buildConf = readBuildConfig();
|
||||
installer(buildConf);
|
||||
|
||||
46
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "element-web",
|
||||
"version": "1.12.4",
|
||||
"version": "1.12.5",
|
||||
"description": "Element: the future of secure communication",
|
||||
"author": "New Vector Ltd.",
|
||||
"repository": {
|
||||
@@ -38,16 +38,16 @@
|
||||
"clean": "rimraf lib webapp",
|
||||
"build": "yarn clean && yarn build:genfiles && yarn build:bundle",
|
||||
"build-stats": "yarn clean && yarn build:genfiles && yarn build:bundle-stats",
|
||||
"build:res": "ts-node scripts/copy-res.ts",
|
||||
"build:res": "node scripts/copy-res.ts",
|
||||
"build:genfiles": "yarn build:res && yarn build:module_system",
|
||||
"build:modernizr": "modernizr -c .modernizr.json -d src/vector/modernizr.js",
|
||||
"build:bundle": "webpack --progress --mode production",
|
||||
"build:bundle-stats": "webpack --progress --mode production --json > webpack-stats.json",
|
||||
"build:module_system": "ts-node --project ./tsconfig.module_system.json module_system/scripts/install.ts",
|
||||
"build:module_system": "node module_system/scripts/install.ts",
|
||||
"dist": "./scripts/package.sh",
|
||||
"start": "concurrently --kill-others-on-fail --prefix \"{time} [{name}]\" -n modules,res \"yarn build:module_system\" \"yarn build:res\" && concurrently --kill-others-on-fail --prefix \"{time} [{name}]\" -n res,element-js \"yarn start:res\" \"yarn start:js\"",
|
||||
"start:https": "concurrently --kill-others-on-fail --prefix \"{time} [{name}]\" -n res,element-js \"yarn start:res\" \"yarn start:js --server-type https\"",
|
||||
"start:res": "ts-node scripts/copy-res.ts -w",
|
||||
"start:res": "node scripts/copy-res.ts -w",
|
||||
"start:js": "webpack serve --output-path webapp --output-filename=bundles/_dev_/[name].js --output-chunk-filename=bundles/_dev_/[name].js --mode development",
|
||||
"lint": "yarn lint:types && yarn lint:js && yarn lint:style && yarn lint:workflows",
|
||||
"lint:js": "eslint --max-warnings 0 src test playwright module_system && prettier --check .",
|
||||
@@ -73,23 +73,22 @@
|
||||
"@playwright/test": "1.56.1",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"@types/serve-static": "1.15.10",
|
||||
"oidc-client-ts": "3.3.0",
|
||||
"jwt-decode": "4.0.0",
|
||||
"caniuse-lite": "1.0.30001751",
|
||||
"caniuse-lite": "1.0.30001754",
|
||||
"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.5.0",
|
||||
"@element-hq/element-web-module-api": "1.6.0",
|
||||
"@element-hq/web-shared-components": "link:packages/shared-components",
|
||||
"@fontsource/inconsolata": "^5",
|
||||
"@fontsource/fira-code": "^5",
|
||||
"@fontsource/inter": "^5",
|
||||
"@formatjs/intl-segmenter": "^11.5.7",
|
||||
"@matrix-org/analytics-events": "^0.29.2",
|
||||
"@matrix-org/emojibase-bindings": "^1.3.4",
|
||||
"@matrix-org/analytics-events": "^0.30.0",
|
||||
"@matrix-org/emojibase-bindings": "^1.5.0",
|
||||
"@matrix-org/react-sdk-module-api": "^2.4.0",
|
||||
"@matrix-org/spec": "^1.7.0",
|
||||
"@sentry/browser": "^10.0.0",
|
||||
@@ -110,7 +109,7 @@
|
||||
"diff-dom": "^5.0.0",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"domutils": "^3.2.2",
|
||||
"emojibase-regex": "15.3.2",
|
||||
"emojibase-regex": "^17.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"file-saver": "^2.0.5",
|
||||
"filesize": "11.0.13",
|
||||
@@ -132,15 +131,15 @@
|
||||
"maplibre-gl": "^5.0.0",
|
||||
"matrix-encrypt-attachment": "^1.0.3",
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
"matrix-js-sdk": "39.2.0",
|
||||
"matrix-widget-api": "^1.10.0",
|
||||
"matrix-js-sdk": "39.3.0",
|
||||
"matrix-widget-api": "^1.14.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"mime": "^4.0.4",
|
||||
"oidc-client-ts": "^3.0.1",
|
||||
"opus-recorder": "^8.0.3",
|
||||
"pako": "^2.0.3",
|
||||
"png-chunks-extract": "^1.0.0",
|
||||
"posthog-js": "1.280.1",
|
||||
"posthog-js": "1.290.0",
|
||||
"qrcode": "1.5.4",
|
||||
"re-resizable": "6.11.2",
|
||||
"react": "^19.0.0",
|
||||
@@ -182,14 +181,14 @@
|
||||
"@babel/preset-react": "^7.12.10",
|
||||
"@babel/preset-typescript": "^7.12.7",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@casualbot/jest-sonar-reporter": "2.2.7",
|
||||
"@casualbot/jest-sonar-reporter": "2.4.0",
|
||||
"@element-hq/element-call-embedded": "0.16.1",
|
||||
"@element-hq/element-web-playwright-common": "^2.0.0",
|
||||
"@peculiar/webcrypto": "^1.4.3",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@principalstudio/html-webpack-inject-preload": "^1.2.7",
|
||||
"@sentry/webpack-plugin": "^4.0.0",
|
||||
"@storybook/react-vite": "^9.1.10",
|
||||
"@storybook/react-vite": "^10.0.7",
|
||||
"@stylistic/eslint-plugin": "^5.0.0",
|
||||
"@svgr/webpack": "^8.0.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
@@ -205,7 +204,7 @@
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/file-saver": "^2.0.3",
|
||||
"@types/glob-to-regexp": "^0.4.1",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/jitsi-meet": "^2.0.2",
|
||||
"@types/jsrsasign": "^10.5.4",
|
||||
"@types/katex": "^0.16.0",
|
||||
@@ -227,7 +226,7 @@
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@typescript-eslint/eslint-plugin": "^8.19.0",
|
||||
"@typescript-eslint/parser": "^8.19.0",
|
||||
"babel-jest": "^29.0.0",
|
||||
"babel-jest": "^30.0.0",
|
||||
"babel-loader": "^10.0.0",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"blob-polyfill": "^9.0.0",
|
||||
@@ -259,10 +258,10 @@
|
||||
"html-webpack-plugin": "^5.5.3",
|
||||
"husky": "^9.0.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^29.6.2",
|
||||
"jest": "^30.0.0",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-mock": "^29.6.2",
|
||||
"jest-environment-jsdom": "^30.0.0",
|
||||
"jest-mock": "^30.0.0",
|
||||
"jest-raw-loader": "^1.0.1",
|
||||
"jsqr": "^1.4.0",
|
||||
"knip": "^5.36.2",
|
||||
@@ -290,14 +289,13 @@
|
||||
"rimraf": "^6.0.0",
|
||||
"semver": "^7.5.2",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"storybook": "^9.1.10",
|
||||
"storybook": "^10.0.7",
|
||||
"stylelint": "^16.23.0",
|
||||
"stylelint-config-standard": "^39.0.0",
|
||||
"stylelint-scss": "^6.0.0",
|
||||
"stylelint-value-no-unknown-custom-properties": "^6.0.1",
|
||||
"terser-webpack-plugin": "^5.3.9",
|
||||
"testcontainers": "^11.0.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "5.8.3",
|
||||
"util": "^0.12.5",
|
||||
"web-streams-polyfill": "^4.0.0",
|
||||
@@ -315,7 +313,7 @@
|
||||
"relativePaths": true
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=22.18"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
@@ -5,17 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { waitForPageReady } from "@storybook/test-runner";
|
||||
import { waitForPageReady, TestRunnerConfig } from "@storybook/test-runner";
|
||||
import { toMatchImageSnapshot } from "jest-image-snapshot";
|
||||
|
||||
const customSnapshotsDir = `${process.cwd()}/playwright/snapshots/`;
|
||||
const customReceivedDir = `${process.cwd()}/playwright/received/`;
|
||||
|
||||
/**
|
||||
* @type {import('@storybook/test-runner').TestRunnerConfig}
|
||||
*/
|
||||
const config = {
|
||||
setup(page) {
|
||||
const config: TestRunnerConfig = {
|
||||
setup() {
|
||||
expect.extend({ toMatchImageSnapshot });
|
||||
},
|
||||
async postVisit(page, context) {
|
||||
@@ -30,7 +30,7 @@ const config: Config = {
|
||||
"workers/(.+)Factory": "<rootDir>/__mocks__/workerFactoryMock.js",
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
"/node_modules/(?!(mime|matrix-js-sdk|uuid|p-retry|is-network-error|react-merge-refs)).+$",
|
||||
"/node_modules/(?!(mime|matrix-js-sdk|uuid|p-retry|is-network-error|react-merge-refs|@storybook|storybook)).+$",
|
||||
],
|
||||
collectCoverageFrom: [
|
||||
"<rootDir>/src/**/*.{js,ts,tsx}",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@element-hq/web-shared-components",
|
||||
"version": "0.0.0-test.7",
|
||||
"version": "0.0.0-test.8",
|
||||
"description": "Shared components for Element",
|
||||
"author": "New Vector Ltd.",
|
||||
"repository": {
|
||||
@@ -35,7 +35,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"prepare": "patch-package && yarn --cwd ../.. build:res && ts-node scripts/gatherTranslationKeys.ts && vite build",
|
||||
"prepare": "patch-package && yarn --cwd ../.. build:res && node scripts/gatherTranslationKeys.ts && vite build",
|
||||
"storybook": "storybook dev -p 6007",
|
||||
"build-storybook": "storybook build",
|
||||
"lint": "yarn lint:types && yarn lint:js",
|
||||
@@ -57,27 +57,27 @@
|
||||
"devDependencies": {
|
||||
"@element-hq/element-web-playwright-common": "^2.0.0",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@storybook/addon-a11y": "^9.1.10",
|
||||
"@storybook/addon-designs": "^10.0.2",
|
||||
"@storybook/addon-docs": "^9.1.10",
|
||||
"@storybook/addon-a11y": "^10.0.7",
|
||||
"@storybook/addon-designs": "^11.0.1",
|
||||
"@storybook/addon-docs": "^10.0.7",
|
||||
"@storybook/icons": "^1.6.0",
|
||||
"@storybook/react-vite": "^9.1.10",
|
||||
"@storybook/test-runner": "^0.23.0",
|
||||
"@storybook/react-vite": "^10.0.7",
|
||||
"@storybook/test-runner": "^0.24.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/counterpart": "^0.18.4",
|
||||
"@types/jest-image-snapshot": "^6.4.0",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/react": "^19.2.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "8",
|
||||
"eslint-plugin-matrix-org": "^3.0.0",
|
||||
"eslint-plugin-storybook": "^10.0.0",
|
||||
"eslint-plugin-storybook": "^10.0.7",
|
||||
"jest": "^30.2.0",
|
||||
"jest-image-snapshot": "^6.5.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"prettier": "^3.6.2",
|
||||
"storybook": "^9.1.10",
|
||||
"ts-node": "^10.9.2",
|
||||
"storybook": "^10.0.7",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.1.9",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
|
||||
@@ -15,7 +15,10 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const i18nStringsPath = path.resolve(__dirname, "../../../src/i18n/strings/en_EN.json");
|
||||
const outPath = path.resolve(__dirname, "../src/i18nKeys.d.ts");
|
||||
|
||||
@@ -56,6 +59,9 @@ function main() {
|
||||
console.log(`Wrote ${keys.length} keys to ${outPath}`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
if (import.meta.url.startsWith("file:")) {
|
||||
const modulePath = fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] === modulePath) {
|
||||
main();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { type Meta, type StoryObj } from "@storybook/react-vite/*";
|
||||
import { type Meta, type StoryObj } from "@storybook/react-vite";
|
||||
|
||||
import { AvatarWithDetails } from "./AvatarWithDetails";
|
||||
|
||||
|
||||
@@ -53,7 +53,13 @@ export function Pill({ className, children, label, onClick, ...props }: PropsWit
|
||||
{label}
|
||||
</span>
|
||||
{onClick && (
|
||||
<IconButton aria-describedby={id} size="16px" onClick={onClick} aria-label={_t("action|delete")}>
|
||||
<IconButton
|
||||
aria-describedby={id}
|
||||
size="16px"
|
||||
onClick={onClick}
|
||||
aria-label={_t("action|delete")}
|
||||
className="mx_Dialog_nonDialogButton"
|
||||
>
|
||||
<CloseIcon color="var(--cpd-color-icon-tertiary)" />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
@@ -18,7 +18,7 @@ exports[`Pill renders the pill 1`] = `
|
||||
<button
|
||||
aria-describedby="_r_0_"
|
||||
aria-label="Delete"
|
||||
class="_icon-button_1pz9o_8"
|
||||
class="_icon-button_1pz9o_8 mx_Dialog_nonDialogButton"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 16px;"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"esModuleInterop": true,
|
||||
"useDefineForClassFields": true,
|
||||
"module": "es2022",
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "es2022",
|
||||
"noUnusedLocals": true,
|
||||
"sourceMap": false,
|
||||
@@ -21,11 +21,5 @@
|
||||
"rollup/parseAst": ["./node_modules/rollup/dist/parseAst"]
|
||||
}
|
||||
},
|
||||
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
|
||||
"ts-node": {
|
||||
"files": true,
|
||||
"moduleTypes": {
|
||||
"*": "cjs"
|
||||
}
|
||||
}
|
||||
"include": ["./src/**/*.ts", "./src/**/*.tsx"]
|
||||
}
|
||||
|
||||
56
patches/jsdom+26.1.0.patch
Normal file
@@ -0,0 +1,56 @@
|
||||
diff --git a/node_modules/jsdom/lib/jsdom/browser/Window.js b/node_modules/jsdom/lib/jsdom/browser/Window.js
|
||||
index 52d011c..f62f6d6 100644
|
||||
--- a/node_modules/jsdom/lib/jsdom/browser/Window.js
|
||||
+++ b/node_modules/jsdom/lib/jsdom/browser/Window.js
|
||||
@@ -505,10 +505,10 @@ function installOwnProperties(window, options) {
|
||||
event: makeReplaceablePropertyDescriptor("event", window),
|
||||
|
||||
// [LegacyUnforgeable]:
|
||||
- window: { configurable: false },
|
||||
- document: { configurable: false },
|
||||
- location: { configurable: false },
|
||||
- top: { configurable: false }
|
||||
+ window: { configurable: true },
|
||||
+ document: { configurable: true },
|
||||
+ location: { configurable: true },
|
||||
+ top: { configurable: true }
|
||||
});
|
||||
|
||||
|
||||
diff --git a/node_modules/jsdom/lib/jsdom/living/generated/Location.js b/node_modules/jsdom/lib/jsdom/living/generated/Location.js
|
||||
index fc4d1dd..c855bd5 100644
|
||||
--- a/node_modules/jsdom/lib/jsdom/living/generated/Location.js
|
||||
+++ b/node_modules/jsdom/lib/jsdom/living/generated/Location.js
|
||||
@@ -322,19 +322,19 @@ function getUnforgeables(globalObject) {
|
||||
}
|
||||
});
|
||||
Object.defineProperties(unforgeables, {
|
||||
- assign: { configurable: false, writable: false },
|
||||
- replace: { configurable: false, writable: false },
|
||||
- reload: { configurable: false, writable: false },
|
||||
- href: { configurable: false },
|
||||
- toString: { configurable: false, writable: false },
|
||||
- origin: { configurable: false },
|
||||
- protocol: { configurable: false },
|
||||
- host: { configurable: false },
|
||||
- hostname: { configurable: false },
|
||||
- port: { configurable: false },
|
||||
- pathname: { configurable: false },
|
||||
- search: { configurable: false },
|
||||
- hash: { configurable: false }
|
||||
+ assign: { configurable: true, writable: false },
|
||||
+ replace: { configurable: true, writable: false },
|
||||
+ reload: { configurable: true, writable: false },
|
||||
+ href: { configurable: true },
|
||||
+ toString: { configurable: true, writable: false },
|
||||
+ origin: { configurable: true },
|
||||
+ protocol: { configurable: true },
|
||||
+ host: { configurable: true },
|
||||
+ hostname: { configurable: true },
|
||||
+ port: { configurable: true },
|
||||
+ pathname: { configurable: true },
|
||||
+ search: { configurable: true },
|
||||
+ hash: { configurable: true }
|
||||
});
|
||||
unforgeablesMap.set(globalObject, unforgeables);
|
||||
}
|
||||
@@ -49,7 +49,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
/**
|
||||
* Take snapshots of mx_EventTile_last on each layout, outputting log for reference/debugging.
|
||||
* @param detail The snapshot name. Used for outputting logs too.
|
||||
* @param monospace This changes the font used to render the UI from a default one to Inconsolata. Set to false by default.
|
||||
* @param monospace This changes the font used to render the UI from a default one to Fira Code. Set to false by default.
|
||||
*/
|
||||
const takeSnapshots = async (page: Page, app: ElementAppPage, detail: string, monospace = false) => {
|
||||
// Check that the audio player is rendered and its button becomes visible
|
||||
@@ -65,7 +65,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
|
||||
if (monospace) {
|
||||
// Assert that the monospace timer is visible
|
||||
await expect(locator.locator("[role='timer']")).toHaveCSS("font-family", "Inconsolata");
|
||||
await expect(locator.locator("[role='timer']")).toHaveCSS("font-family", '"Fira Code"');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,7 +73,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
// Enable system font and monospace setting
|
||||
await app.settings.setValue("useBundledEmojiFont", null, SettingLevel.DEVICE, false);
|
||||
await app.settings.setValue("useSystemFont", null, SettingLevel.DEVICE, true);
|
||||
await app.settings.setValue("systemFont", null, SettingLevel.DEVICE, "Inconsolata");
|
||||
await app.settings.setValue("systemFont", null, SettingLevel.DEVICE, "Fira Code");
|
||||
}
|
||||
|
||||
// Check the status of the seek bar
|
||||
|
||||
@@ -49,7 +49,10 @@ test.describe("Encryption state after registration", () => {
|
||||
"Pa$sW0rD!",
|
||||
);
|
||||
|
||||
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
|
||||
await page
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New conversation" })
|
||||
.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();
|
||||
@@ -78,7 +81,10 @@ test.describe("Key backup reset from elsewhere", () => {
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await registerAccountMas(page, mailpitClient, testUsername, `${testUsername}@email.com`, testPassword);
|
||||
|
||||
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
|
||||
await page
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New conversation" })
|
||||
.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();
|
||||
|
||||
@@ -21,7 +21,7 @@ const checkDMRoom = async (page: Page) => {
|
||||
};
|
||||
|
||||
const startDMWithBob = async (page: Page, bob: Bot) => {
|
||||
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
|
||||
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "New conversation" }).click();
|
||||
await page.getByRole("menuitem", { name: "Start chat" }).click();
|
||||
await page.getByTestId("invite-dialog-input").fill(bob.credentials.userId);
|
||||
await page.getByRole("option", { name: bob.credentials.displayName }).click();
|
||||
|
||||
@@ -23,7 +23,10 @@ test.describe("Key storage out of sync toast", () => {
|
||||
await deleteCachedSecrets(page);
|
||||
|
||||
// We won't be prompted for crypto setup unless we have an e2e room, so make one
|
||||
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
|
||||
await page
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New conversation" })
|
||||
.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();
|
||||
@@ -68,7 +71,10 @@ test.describe("'Turn on key storage' toast", () => {
|
||||
await logIntoElementAndVerify(page, credentials, recoveryKey.encodedPrivateKey);
|
||||
|
||||
// We won't be prompted for crypto setup unless we have an e2e room, so make one
|
||||
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
|
||||
await page
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New conversation" })
|
||||
.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();
|
||||
|
||||
@@ -438,7 +438,7 @@ export async function sendMessageInCurrentRoom(page: Page, message: string): Pro
|
||||
* @param isEncrypted - Whether the room should be encrypted
|
||||
*/
|
||||
export async function createRoom(page: Page, roomName: string, isEncrypted: boolean): Promise<void> {
|
||||
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
|
||||
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "New conversation" }).click();
|
||||
await page.getByRole("menuitem", { name: "New room" }).click();
|
||||
|
||||
const dialog = page.locator(".mx_Dialog");
|
||||
|
||||
@@ -73,7 +73,10 @@ test.describe("Invite dialog", function () {
|
||||
"should support inviting a user to Direct Messages",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, app, user, bot }) => {
|
||||
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
|
||||
await page
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New conversation" })
|
||||
.click();
|
||||
await page.getByRole("menuitem", { name: "Start chat" }).click();
|
||||
|
||||
const other = page.locator(".mx_InviteDialog_other");
|
||||
|
||||
@@ -30,7 +30,7 @@ test.describe("Header section of the room list", () => {
|
||||
const roomListHeader = getHeaderSection(page);
|
||||
await expect(roomListHeader).toMatchScreenshot("room-list-header.png");
|
||||
|
||||
const composeMenu = roomListHeader.getByRole("button", { name: "Add" });
|
||||
const composeMenu = roomListHeader.getByRole("button", { name: "New conversation" });
|
||||
await composeMenu.click();
|
||||
|
||||
await expect(page.getByRole("menu")).toMatchScreenshot("room-list-header-compose-menu.png");
|
||||
@@ -55,7 +55,7 @@ test.describe("Header section of the room list", () => {
|
||||
await expect(roomListHeader).toMatchScreenshot("room-list-space-header.png");
|
||||
|
||||
await expect(roomListHeader.getByRole("heading", { name: "MySpace" })).toBeVisible();
|
||||
await expect(roomListHeader.getByRole("button", { name: "Add" })).toBeVisible();
|
||||
await expect(roomListHeader.getByRole("button", { name: "New conversation" })).toBeVisible();
|
||||
|
||||
const spaceMenu = roomListHeader.getByRole("button", { name: "Open space menu" });
|
||||
await spaceMenu.click();
|
||||
|
||||
@@ -315,7 +315,10 @@ test.describe("Room list", () => {
|
||||
});
|
||||
|
||||
test("should be a video room", { tag: "@screenshot" }, async ({ page, app, user }) => {
|
||||
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
|
||||
await page
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New conversation" })
|
||||
.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();
|
||||
|
||||
@@ -46,24 +46,21 @@ test.describe("Location sharing", { tag: "@no-firefox" }, () => {
|
||||
|
||||
await submitShareLocation(page);
|
||||
|
||||
await page.locator(".mx_RoomView_body .mx_EventTile .mx_MLocationBody").click({
|
||||
position: {
|
||||
x: 225,
|
||||
y: 150,
|
||||
},
|
||||
});
|
||||
await page.getByRole("button", { name: "Map marker" }).click();
|
||||
|
||||
// Wait for map to load
|
||||
await expect(page.getByRole("region", { name: "Map" })).toMatchScreenshot(
|
||||
const dialog = page.getByRole("dialog");
|
||||
|
||||
// wait for the dialog to be visible
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// screenshot the map within the dialog
|
||||
await expect(dialog.getByRole("region", { name: "Map" })).toMatchScreenshot(
|
||||
"location-pin-drop-message-map.png",
|
||||
);
|
||||
|
||||
// clicking location tile opens maximised map
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
|
||||
await app.closeDialog();
|
||||
|
||||
await expect(page.locator(".mx_Marker")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Map marker" })).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ test.describe("Login", () => {
|
||||
await expect(page.getByRole("heading", { name: "Welcome to Element!" })).toBeVisible();
|
||||
|
||||
// Start the login process
|
||||
await expect(axe).toHaveNoViolations();
|
||||
await page.getByRole("link", { name: "Sign in" }).click();
|
||||
|
||||
// first pick the homeserver, as otherwise the user picker won't be visible
|
||||
@@ -148,8 +149,6 @@ test.describe("Login", () => {
|
||||
await selectHomeserver(page, homeserver.baseUrl);
|
||||
|
||||
await expect(page.getByRole("textbox", { name: "Username" })).toBeVisible();
|
||||
// Disabled because flaky - see https://github.com/vector-im/element-web/issues/24688
|
||||
// cy.percySnapshot("Login");
|
||||
await expect(axe).toHaveNoViolations();
|
||||
|
||||
await page.getByRole("textbox", { name: "Username" }).fill(credentials.username);
|
||||
|
||||
@@ -80,13 +80,12 @@ test.describe("Memberlist", () => {
|
||||
await app.scrollListToBottom(memberListContainer);
|
||||
|
||||
// Wait for the target member to be visible after scrolling
|
||||
const targetName = "Member14";
|
||||
// Member9 is the last in the list as they are lexicographically sorted
|
||||
const targetName = "Member9";
|
||||
const targetMember = memberlist.locator(".mx_MemberTileView_name").filter({ hasText: targetName });
|
||||
await targetMember.waitFor({ state: "visible" });
|
||||
|
||||
// Verify Alice is not visible at this point
|
||||
await expect(memberlist.locator(".mx_MemberTileView_name").filter({ hasText: "Alice" })).toHaveCount(0);
|
||||
|
||||
// Alice is not visible and will require scrolling to,
|
||||
// but is likely in the dom as we have an overscan on the top and bottom of the list.
|
||||
// Click on a member near the bottom of the list
|
||||
await expect(targetMember).toBeVisible();
|
||||
await targetMember.click();
|
||||
|
||||
@@ -164,7 +164,7 @@ test.describe("RightPanel", () => {
|
||||
css: `
|
||||
/* Use monospace font for consistent mask width */
|
||||
.mx_UserInfo_profile_mxid {
|
||||
font-family: Inconsolata !important;
|
||||
font-family: "Fira Code" !important;
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ test.describe("Create Room", () => {
|
||||
);
|
||||
|
||||
test("should allow us to start a chat and show encryption state", async ({ page, user, app }) => {
|
||||
await page.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await page.getByRole("button", { name: "New conversation", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Start chat" }).click();
|
||||
|
||||
await page.getByTestId("invite-dialog-input").fill(user.userId);
|
||||
|
||||
@@ -373,7 +373,7 @@ test.describe("Threads", () => {
|
||||
|
||||
// Exclude timestamp, read marker, and maplibregl-map from snapshots
|
||||
const css =
|
||||
".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker, .maplibregl-map { visibility: hidden !important; }";
|
||||
".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker, .maplibregl-map, .maplibregl-ctrl-attrib { visibility: hidden !important; }";
|
||||
|
||||
let locator = page.locator(".mx_RoomView_body");
|
||||
// User sends message
|
||||
|
||||
@@ -24,7 +24,7 @@ test.describe("UserView", () => {
|
||||
css: `
|
||||
/* Use monospace font for consistent mask width */
|
||||
.mx_UserInfo_profile_mxid {
|
||||
font-family: Inconsolata !important;
|
||||
font-family: "Fira Code" !important;
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { EventType, Preset } from "matrix-js-sdk/src/matrix";
|
||||
import { SettingLevel } from "../../../src/settings/SettingLevel";
|
||||
import { test, expect } from "../../element-web-test";
|
||||
import type { Credentials } from "../../plugins/homeserver";
|
||||
import type { Bot } from "../../pages/bot";
|
||||
import { Bot } from "../../pages/bot";
|
||||
|
||||
function assertCommonCallParameters(
|
||||
url: URLSearchParams,
|
||||
@@ -27,27 +27,28 @@ function assertCommonCallParameters(
|
||||
expect(hash.get("preload")).toEqual("false");
|
||||
}
|
||||
|
||||
async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "notification") {
|
||||
async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "notification", intent?: string) {
|
||||
const resp = await bot.sendStateEvent(
|
||||
roomId,
|
||||
"org.matrix.msc3401.call.member",
|
||||
{
|
||||
application: "m.call",
|
||||
call_id: "",
|
||||
device_id: "OiDFxsZrjz",
|
||||
expires: 180000000,
|
||||
foci_preferred: [
|
||||
"application": "m.call",
|
||||
"call_id": "",
|
||||
"m.call.intent": intent,
|
||||
"device_id": "OiDFxsZrjz",
|
||||
"expires": 180000000,
|
||||
"foci_preferred": [
|
||||
{
|
||||
livekit_alias: roomId,
|
||||
livekit_service_url: "https://example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
],
|
||||
focus_active: {
|
||||
"focus_active": {
|
||||
focus_selection: "oldest_membership",
|
||||
type: "livekit",
|
||||
},
|
||||
scope: "m.room",
|
||||
"scope": "m.room",
|
||||
},
|
||||
`_@${bot.credentials.userId}_OiDFxsZrjz_m.call`,
|
||||
);
|
||||
@@ -64,6 +65,7 @@ async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "n
|
||||
event_id: resp.event_id,
|
||||
rel_type: "org.matrix.msc4075.rtc.notification.parent",
|
||||
},
|
||||
"m.call.intent": intent,
|
||||
"notification_type": notification,
|
||||
"sender_ts": 1758611895996,
|
||||
});
|
||||
@@ -103,15 +105,21 @@ test.describe("Element Call", () => {
|
||||
});
|
||||
|
||||
test.describe("Group Chat", () => {
|
||||
let charlie: Bot;
|
||||
test.use({
|
||||
room: async ({ page, app, user, bot }, use) => {
|
||||
const roomId = await app.client.createRoom({ name: "TestRoom", invite: [bot.credentials.userId] });
|
||||
room: async ({ page, app, user, homeserver, bot }, use) => {
|
||||
charlie = new Bot(page, homeserver, { displayName: "Charlie" });
|
||||
await charlie.prepareClient();
|
||||
const roomId = await app.client.createRoom({
|
||||
name: "TestRoom",
|
||||
invite: [bot.credentials.userId, charlie.credentials.userId],
|
||||
});
|
||||
await use({ roomId });
|
||||
},
|
||||
});
|
||||
test("should be able to start a video call", async ({ page, user, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
await expect(page.getByText("Bob joined the room")).toBeVisible();
|
||||
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Video call" }).click();
|
||||
await page.getByRole("menuitem", { name: "Element Call" }).click();
|
||||
@@ -126,9 +134,16 @@ test.describe("Element Call", () => {
|
||||
expect(hash.get("skipLobby")).toEqual(null);
|
||||
});
|
||||
|
||||
test("should NOT be able to start a voice call", async ({ page, user, room, app }) => {
|
||||
// Voice calls do not exist in group rooms
|
||||
await app.viewRoomById(room.roomId);
|
||||
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Voice call" })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("should be able to skip lobby by holding down shift", async ({ page, user, bot, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
await expect(page.getByText("Bob joined the room")).toBeVisible();
|
||||
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Video call" }).click();
|
||||
await page.keyboard.down("Shift");
|
||||
@@ -147,8 +162,8 @@ test.describe("Element Call", () => {
|
||||
test("should be able to join a call in progress", async ({ page, user, bot, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
// Allow bob to create a call
|
||||
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
|
||||
await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50);
|
||||
await expect(page.getByText("Bob joined the room")).toBeVisible();
|
||||
// Fake a start of a call
|
||||
await sendRTCState(bot, room.roomId);
|
||||
const button = page.getByTestId("join-call-button");
|
||||
@@ -156,7 +171,6 @@ test.describe("Element Call", () => {
|
||||
// And test joining
|
||||
await button.click();
|
||||
const frameUrlStr = await page.locator("iframe").getAttribute("src");
|
||||
console.log(frameUrlStr);
|
||||
await expect(frameUrlStr).toBeDefined();
|
||||
const url = new URL(frameUrlStr);
|
||||
const hash = new URLSearchParams(url.hash.slice(1));
|
||||
@@ -168,29 +182,29 @@ test.describe("Element Call", () => {
|
||||
|
||||
[true, false].forEach((skipLobbyToggle) => {
|
||||
test(
|
||||
`should be able to join a call via incoming call toast (skipLobby=${skipLobbyToggle})`,
|
||||
`should be able to join a call via incoming video call toast (skipLobby=${skipLobbyToggle})`,
|
||||
{ tag: ["@screenshot"] },
|
||||
async ({ page, user, bot, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
// Allow bob to create a call
|
||||
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
|
||||
await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50);
|
||||
await expect(page.getByText("Bob joined the room")).toBeVisible();
|
||||
// Fake a start of a call
|
||||
await sendRTCState(bot, room.roomId, "notification");
|
||||
await sendRTCState(bot, room.roomId, "notification", "video");
|
||||
const toast = page.locator(".mx_Toast_toast");
|
||||
const button = toast.getByRole("button", { name: "Join" });
|
||||
|
||||
if (skipLobbyToggle) {
|
||||
await toast.getByRole("switch").check();
|
||||
await expect(toast).toMatchScreenshot("incoming-call-group-video-toast-checked.png");
|
||||
await expect(toast).toMatchScreenshot(`incoming-call-group-video-toast-checked.png`);
|
||||
} else {
|
||||
await toast.getByRole("switch").uncheck();
|
||||
await expect(toast).toMatchScreenshot("incoming-call-group-video-toast-unchecked.png");
|
||||
await expect(toast).toMatchScreenshot(`incoming-call-group-video-toast-unchecked.png`);
|
||||
}
|
||||
|
||||
// And test joining
|
||||
await button.click();
|
||||
const frameUrlStr = await page.locator("iframe").getAttribute("src");
|
||||
console.log(frameUrlStr);
|
||||
await expect(frameUrlStr).toBeDefined();
|
||||
const url = new URL(frameUrlStr);
|
||||
const hash = new URLSearchParams(url.hash.slice(1));
|
||||
@@ -201,6 +215,34 @@ test.describe("Element Call", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
`should be able to join a call via incoming voice call toast`,
|
||||
{ tag: ["@screenshot"] },
|
||||
async ({ page, user, bot, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
// Allow bob to create a call
|
||||
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
|
||||
await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50);
|
||||
// Fake a start of a call
|
||||
await sendRTCState(bot, room.roomId, "notification", "audio");
|
||||
const toast = page.locator(".mx_Toast_toast");
|
||||
const button = toast.getByRole("button", { name: "Join" });
|
||||
|
||||
await expect(toast).toMatchScreenshot(`incoming-call-group-voice-toast.png`);
|
||||
|
||||
// And test joining
|
||||
await button.click();
|
||||
const frameUrlStr = await page.locator("iframe").getAttribute("src");
|
||||
await expect(frameUrlStr).toBeDefined();
|
||||
const url = new URL(frameUrlStr);
|
||||
const hash = new URLSearchParams(url.hash.slice(1));
|
||||
assertCommonCallParameters(url.searchParams, hash, user, room);
|
||||
|
||||
expect(hash.get("intent")).toEqual("join_existing");
|
||||
expect(hash.get("skipLobby")).toEqual("true");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.describe("DMs", () => {
|
||||
@@ -253,7 +295,6 @@ test.describe("Element Call", () => {
|
||||
|
||||
test("should be able to join a call in progress", async ({ page, user, bot, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
// Allow bob to create a call
|
||||
await expect(page.getByText("Bob joined the room")).toBeVisible();
|
||||
// Fake a start of a call
|
||||
await sendRTCState(bot, room.roomId);
|
||||
@@ -262,7 +303,6 @@ test.describe("Element Call", () => {
|
||||
// And test joining
|
||||
await button.click();
|
||||
const frameUrlStr = await page.locator("iframe").getAttribute("src");
|
||||
console.log(frameUrlStr);
|
||||
await expect(frameUrlStr).toBeDefined();
|
||||
const url = new URL(frameUrlStr);
|
||||
const hash = new URLSearchParams(url.hash.slice(1));
|
||||
@@ -278,24 +318,31 @@ test.describe("Element Call", () => {
|
||||
{ tag: ["@screenshot"] },
|
||||
async ({ page, user, bot, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
// Allow bob to create a call
|
||||
await expect(page.getByText("Bob joined the room")).toBeVisible();
|
||||
// Fake a start of a call
|
||||
await sendRTCState(bot, room.roomId, "ring");
|
||||
await sendRTCState(bot, room.roomId, "ring", "video");
|
||||
const toast = page.locator(".mx_Toast_toast");
|
||||
const button = toast.getByRole("button", { name: "Join" });
|
||||
const button = toast.getByRole("button", { name: "Accept" });
|
||||
if (skipLobbyToggle) {
|
||||
await toast.getByRole("switch").check();
|
||||
await expect(toast).toMatchScreenshot("incoming-call-dm-video-toast-checked.png");
|
||||
} else {
|
||||
await toast.getByRole("switch").uncheck();
|
||||
await expect(toast).toMatchScreenshot("incoming-call-dm-video-toast-unchecked.png");
|
||||
}
|
||||
await expect(toast).toMatchScreenshot(
|
||||
`incoming-call-dm-video-toast-${skipLobbyToggle ? "checked" : "unchecked"}.png`,
|
||||
{
|
||||
// Hide UserId
|
||||
css: `
|
||||
.mx_IncomingCallToast_AvatarWithDetails span:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
`,
|
||||
},
|
||||
);
|
||||
|
||||
// And test joining
|
||||
await button.click();
|
||||
const frameUrlStr = await page.locator("iframe").getAttribute("src");
|
||||
console.log(frameUrlStr);
|
||||
await expect(frameUrlStr).toBeDefined();
|
||||
const url = new URL(frameUrlStr);
|
||||
const hash = new URLSearchParams(url.hash.slice(1));
|
||||
@@ -306,6 +353,39 @@ test.describe("Element Call", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
`should be able to join a call via incoming voice call toast`,
|
||||
{ tag: ["@screenshot"] },
|
||||
async ({ page, user, bot, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
await expect(page.getByText("Bob joined the room")).toBeVisible();
|
||||
// Fake a start of a call
|
||||
await sendRTCState(bot, room.roomId, "ring", "audio");
|
||||
const toast = page.locator(".mx_Toast_toast");
|
||||
const button = toast.getByRole("button", { name: "Accept" });
|
||||
|
||||
await expect(toast).toMatchScreenshot(`incoming-call-dm-voice-toast.png`, {
|
||||
// Hide UserId
|
||||
css: `
|
||||
.mx_IncomingCallToast_AvatarWithDetails span:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
// And test joining
|
||||
await button.click();
|
||||
const frameUrlStr = await page.locator("iframe").getAttribute("src");
|
||||
await expect(frameUrlStr).toBeDefined();
|
||||
const url = new URL(frameUrlStr);
|
||||
const hash = new URLSearchParams(url.hash.slice(1));
|
||||
assertCommonCallParameters(url.searchParams, hash, user, room);
|
||||
|
||||
expect(hash.get("intent")).toEqual("join_existing_dm_voice");
|
||||
expect(hash.get("skipLobby")).toEqual("true");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.describe("Video Rooms", () => {
|
||||
@@ -318,7 +398,10 @@ test.describe("Element Call", () => {
|
||||
},
|
||||
});
|
||||
test("should be able to create and join a video room", async ({ page, user }) => {
|
||||
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
|
||||
await page
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New conversation" })
|
||||
.click();
|
||||
await page.getByRole("menuitem", { name: "New video room" }).click();
|
||||
await page.getByRole("textbox", { name: "Name" }).fill("Test room");
|
||||
await page.getByRole("button", { name: "Create video room" }).click();
|
||||
|
||||
@@ -144,7 +144,7 @@ export const expect = baseExpect.extend<Expectations>({
|
||||
}
|
||||
/* Use monospace font for timestamp for consistent mask width */
|
||||
.mx_MessageTimestamp {
|
||||
font-family: Inconsolata !important;
|
||||
font-family: "Fira Code" !important;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -53,7 +53,10 @@ export class ElementAppPage {
|
||||
*/
|
||||
|
||||
public async openCreateRoomDialog(roomKindname: "New room" | "New video room" = "New room"): Promise<Locator> {
|
||||
await this.page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
|
||||
await this.page
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New conversation" })
|
||||
.click();
|
||||
await this.page.getByRole("menuitem", { name: roomKindname }).click();
|
||||
return this.page.locator(".mx_CreateRoomDialog");
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -10,7 +10,7 @@ import {
|
||||
type StartedPostgreSqlContainer,
|
||||
} from "@element-hq/element-web-playwright-common/lib/testcontainers";
|
||||
|
||||
const TAG = "main@sha256:5957b8a5377c9f767b844fa7f3c800fdb1f7d3d95d3d218fe000fdd2d8f0f2a6";
|
||||
const TAG = "main@sha256:cebb2d1064e942e03713bcc00f96a9c6f345698dafc28be471ab5084bef97033";
|
||||
|
||||
/**
|
||||
* MatrixAuthenticationServiceContainer which freezes the docker digest to
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
|
||||
|
||||
const TAG = "develop@sha256:c655aea58939588cbf774d385bd7969f4f920e905f9fcf87615995c262c4917e";
|
||||
const TAG = "develop@sha256:21d2595edd0f3172fe57b9a65e511632e3a9f9ab7bba3ef61965f4cab870107d";
|
||||
|
||||
/**
|
||||
* SynapseContainer which freezes the docker digest to stabilise tests,
|
||||
|
||||
@@ -18,9 +18,6 @@ Please see LICENSE files in the repository root for full details.
|
||||
flex-direction: column;
|
||||
max-width: 50%;
|
||||
position: relative;
|
||||
|
||||
/* Contain the amount of layers rendered by constraining what actually needs re-layering via css */
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.mx_LeftPanel_wrapper,
|
||||
|
||||
@@ -25,6 +25,10 @@ Please see LICENSE files in the repository root for full details.
|
||||
mask-image: url("$(res)/img/element-icons/call/video-call.svg");
|
||||
}
|
||||
|
||||
&.mx_LiveContentSummary_text_voice::before {
|
||||
mask-image: url("$(res)/img/element-icons/call/voice-call.svg");
|
||||
}
|
||||
|
||||
&.mx_LiveContentSummary_text_active {
|
||||
color: $accent;
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
color: var(--cpd-color-text-secondary);
|
||||
|
||||
.mx_KeyPanel_key {
|
||||
font-family: Inconsolata, monospace;
|
||||
font-family: "Fira Code", monospace;
|
||||
/*
|
||||
* From figma https://www.figma.com/design/qTWRfItpO3RdCjnTKPu4mL/Settings?node-id=375-77471&t=t7lozYrSI1AVZZ3U-4
|
||||
*/
|
||||
|
||||
@@ -15,7 +15,7 @@ $font-family:
|
||||
"Noto Color Emoji";
|
||||
|
||||
$monospace-font-family:
|
||||
"Inconsolata", var(--emoji-font-family), "Apple Color Emoji", "Segoe UI Emoji", "Courier", monospace,
|
||||
"Fira Code", var(--emoji-font-family), "Apple Color Emoji", "Segoe UI Emoji", "Courier", monospace,
|
||||
"Noto Color Emoji";
|
||||
|
||||
/* unified palette */
|
||||
|
||||
@@ -15,7 +15,7 @@ $font-family:
|
||||
"Noto Color Emoji";
|
||||
|
||||
$monospace-font-family:
|
||||
"Inconsolata", var(--emoji-font-family), "Apple Color Emoji", "Segoe UI Emoji", "Courier", monospace,
|
||||
"Fira Code", var(--emoji-font-family), "Apple Color Emoji", "Segoe UI Emoji", "Courier", monospace,
|
||||
"Noto Color Emoji";
|
||||
|
||||
/* Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=559%3A120 */
|
||||
|
||||
@@ -167,11 +167,11 @@ we don't have an account and should hide them. No account == no guest account ei
|
||||
|
||||
<div class="mx_Parent">
|
||||
<a href="https://element.io" target="_blank" rel="noopener">
|
||||
<img src="$logoUrl" alt="" class="mx_Logo" />
|
||||
<img src="$logoUrl" alt="$brand" class="mx_Logo" />
|
||||
</a>
|
||||
<h1 class="mx_Header_title">_t("welcome_to_element")</h1>
|
||||
<!-- XXX: Our translations system isn't smart enough to recognize variables in the HTML, so we manually do it -->
|
||||
<h4 class="mx_Header_subtitle">_t("powered_by_matrix_with_logo")</h4>
|
||||
<h2 class="mx_Header_subtitle">_t("powered_by_matrix_with_logo")</h2>
|
||||
<div class="mx_ButtonGroup">
|
||||
<div class="mx_ButtonRow">
|
||||
<a href="#/login" class="mx_ButtonParent mx_ButtonSignIn mx_Button_iconSignIn">
|
||||
|
||||
@@ -6,8 +6,8 @@ import parseArgs from "minimist";
|
||||
import * as chokidar from "chokidar";
|
||||
import * as fs from "node:fs";
|
||||
import _ from "lodash";
|
||||
import { util } from "webpack";
|
||||
import { Translations } from "matrix-web-i18n";
|
||||
import webpack from "webpack";
|
||||
import type { Translations } from "matrix-web-i18n";
|
||||
|
||||
const I18N_BASE_PATH = "src/i18n/strings/";
|
||||
const INCLUDE_LANGS = [...new Set([...fs.readdirSync(I18N_BASE_PATH)])]
|
||||
@@ -58,7 +58,7 @@ function prepareLangFile(lang: string, dest: string): [filename: string, json: s
|
||||
|
||||
const json = JSON.stringify(translations, null, 4);
|
||||
const jsonBuffer = Buffer.from(json);
|
||||
const digest = util.createHash("xxhash64").update(jsonBuffer).digest("hex").slice(0, 7);
|
||||
const digest = webpack.util.createHash("xxhash64").update(jsonBuffer).digest("hex").slice(0, 7);
|
||||
const filename = `${lang}.${digest}.json`;
|
||||
|
||||
return [filename, json];
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
import parseArgs from "minimist";
|
||||
import cronstrue from "cronstrue";
|
||||
import { partition } from "lodash";
|
||||
import _ from "lodash";
|
||||
|
||||
const argv = parseArgs<{
|
||||
debug: boolean;
|
||||
@@ -81,7 +81,7 @@ class Graph<T extends Node> {
|
||||
public removeNode(node: T): Edge<T>[] {
|
||||
if (!this.nodes.has(node.id)) return [];
|
||||
this.nodes.delete(node.id);
|
||||
const [removedEdges, keptEdges] = partition(
|
||||
const [removedEdges, keptEdges] = _.partition(
|
||||
this.edges,
|
||||
([source, destination]) => source === node || destination === node,
|
||||
);
|
||||
@@ -384,6 +384,7 @@ class MermaidFlowchartPrinter {
|
||||
private static INDENT = 4;
|
||||
private currentIndent = 0;
|
||||
private text = "";
|
||||
private readonly markdown: boolean;
|
||||
public readonly idGenerator = new IdGenerator();
|
||||
|
||||
private print(text: string): void {
|
||||
@@ -400,11 +401,8 @@ class MermaidFlowchartPrinter {
|
||||
this.currentIndent += delta * MermaidFlowchartPrinter.INDENT;
|
||||
}
|
||||
|
||||
public constructor(
|
||||
direction: "TD" | "TB" | "BT" | "RL" | "LR",
|
||||
title?: string,
|
||||
private readonly markdown = false,
|
||||
) {
|
||||
public constructor(direction: "TD" | "TB" | "BT" | "RL" | "LR", title?: string, markdown = false) {
|
||||
this.markdown = markdown;
|
||||
if (this.markdown) {
|
||||
this.print("```mermaid");
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export default abstract class BasePlatform {
|
||||
|
||||
protected onAction(payload: ActionPayload): void {
|
||||
switch (payload.action) {
|
||||
case "on_client_not_viable":
|
||||
case Action.ClientNotViable:
|
||||
case Action.OnLoggedOut:
|
||||
this.setNotificationCount(0);
|
||||
break;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
@@ -144,6 +145,25 @@ export default class DeviceListener {
|
||||
this.client = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the device listener while a function runs.
|
||||
*
|
||||
* This can be done if the function makes several changes that would trigger
|
||||
* multiple events, to suppress warning toasts until the process is
|
||||
* finished.
|
||||
*/
|
||||
public async whilePaused(fn: () => Promise<void>): Promise<void> {
|
||||
const client = this.client;
|
||||
try {
|
||||
this.stop();
|
||||
await fn();
|
||||
} finally {
|
||||
if (client) {
|
||||
this.start(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss notifications about our own unverified devices
|
||||
*
|
||||
@@ -177,6 +197,67 @@ export default class DeviceListener {
|
||||
await this.client?.setAccountData(RECOVERY_ACCOUNT_DATA_KEY, { enabled: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* If a `Kind.KEY_STORAGE_OUT_OF_SYNC` condition from {@link doRecheck}
|
||||
* requires a reset of cross-signing keys.
|
||||
*
|
||||
* We will reset cross-signing keys if both our local cache and 4S don't
|
||||
* have all cross-signing keys.
|
||||
*
|
||||
* In theory, if the set of keys in our cache and in 4S are different, and
|
||||
* we have a complete set between the two, we could be OK, but that
|
||||
* should be exceptionally rare, and is more complicated to detect.
|
||||
*/
|
||||
public async keyStorageOutOfSyncNeedsCrossSigningReset(forgotRecovery: boolean): Promise<boolean> {
|
||||
const crypto = this.client?.getCrypto();
|
||||
if (!crypto) {
|
||||
return false;
|
||||
}
|
||||
const crossSigningStatus = await crypto.getCrossSigningStatus();
|
||||
const allCrossSigningSecretsCached =
|
||||
crossSigningStatus.privateKeysCachedLocally.masterKey &&
|
||||
crossSigningStatus.privateKeysCachedLocally.selfSigningKey &&
|
||||
crossSigningStatus.privateKeysCachedLocally.userSigningKey;
|
||||
|
||||
if (forgotRecovery) {
|
||||
return !allCrossSigningSecretsCached;
|
||||
} else {
|
||||
return !allCrossSigningSecretsCached && !crossSigningStatus.privateKeysInSecretStorage;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If a `Kind.KEY_STORAGE_OUT_OF_SYNC` condition from {@link doRecheck}
|
||||
* requires a reset of key backup.
|
||||
*
|
||||
* If the user has their recovery key, we need to reset backup if:
|
||||
* - the user hasn't disabled backup,
|
||||
* - we don't have the backup key cached locally, *and*
|
||||
* - we don't have the backup key stored in 4S.
|
||||
* (The user should already have a key backup created at this point,
|
||||
* otherwise `doRecheck` would have triggered a `Kind.TURN_ON_KEY_STORAGE`
|
||||
* condition.)
|
||||
*
|
||||
* If the user has forgotten their recovery key, we need to reset backup if:
|
||||
* - the user hasn't disabled backup, and
|
||||
* - we don't have the backup key locally.
|
||||
*/
|
||||
public async keyStorageOutOfSyncNeedsBackupReset(forgotRecovery: boolean): Promise<boolean> {
|
||||
const crypto = this.client?.getCrypto();
|
||||
if (!crypto) {
|
||||
return false;
|
||||
}
|
||||
const shouldHaveBackup = !(await this.recheckBackupDisabled(this.client!));
|
||||
const backupKeyCached = (await crypto.getSessionBackupPrivateKey()) !== null;
|
||||
const backupKeyStored = await this.client!.isKeyBackupKeyStored();
|
||||
|
||||
if (forgotRecovery) {
|
||||
return shouldHaveBackup && !backupKeyCached;
|
||||
} else {
|
||||
return shouldHaveBackup && !backupKeyCached && !backupKeyStored;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureDeviceIdsAtStartPopulated(): Promise<void> {
|
||||
if (this.ourDeviceIdsAtStart === null) {
|
||||
this.ourDeviceIdsAtStart = await this.getDeviceIds();
|
||||
@@ -318,12 +399,6 @@ export default class DeviceListener {
|
||||
|
||||
const cli = this.client;
|
||||
|
||||
// cross-signing support was added to Matrix in MSC1756, which landed in spec v1.1
|
||||
if (!(await cli.isVersionSupported("v1.1"))) {
|
||||
logSpan.debug("cross-signing not supported");
|
||||
return;
|
||||
}
|
||||
|
||||
const crypto = cli.getCrypto();
|
||||
if (!crypto) {
|
||||
logSpan.debug("crypto not enabled");
|
||||
@@ -363,7 +438,10 @@ export default class DeviceListener {
|
||||
// said we are OK with that.
|
||||
const keyBackupIsOk = keyBackupUploadActive || backupDisabled;
|
||||
|
||||
const allSystemsReady = isCurrentDeviceTrusted && allCrossSigningSecretsCached && keyBackupIsOk && recoveryIsOk;
|
||||
const backupKeyCached = (await crypto.getSessionBackupPrivateKey()) !== null;
|
||||
|
||||
const allSystemsReady =
|
||||
isCurrentDeviceTrusted && allCrossSigningSecretsCached && keyBackupIsOk && recoveryIsOk && backupKeyCached;
|
||||
|
||||
await this.reportCryptoSessionStateToAnalytics(cli);
|
||||
|
||||
@@ -407,15 +485,22 @@ export default class DeviceListener {
|
||||
}
|
||||
} else {
|
||||
// If we get here, then we are verified, have key backup, and
|
||||
// 4S, but crypto.isSecretStorageReady returned false, which
|
||||
// means that 4S doesn't have all the secrets.
|
||||
logSpan.warn("4S is missing secrets", {
|
||||
// 4S, but allSystemsReady is false, which means that either
|
||||
// secretStorageStatus.ready is false (which means that 4S
|
||||
// doesn't have all the secrets), or we don't have the backup
|
||||
// key cached locally.
|
||||
logSpan.warn("4S is missing secrets or backup key not cached", {
|
||||
crossSigningReady,
|
||||
secretStorageStatus,
|
||||
allCrossSigningSecretsCached,
|
||||
isCurrentDeviceTrusted,
|
||||
backupKeyCached,
|
||||
});
|
||||
showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC_STORE);
|
||||
// We use the right toast variant based on whether the backup
|
||||
// key is missing locally. If any of the cross-signing keys are
|
||||
// missing locally, that is handled by the
|
||||
// `!allCrossSigningSecretsCached` branch above.
|
||||
showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC);
|
||||
}
|
||||
} else {
|
||||
logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false");
|
||||
|
||||
@@ -599,6 +599,9 @@ async function abortLogin(): Promise<void> {
|
||||
}
|
||||
|
||||
/** Attempt to restore the session from localStorage or indexeddb.
|
||||
*
|
||||
* If the credentials are found, and the session is successfully restored,
|
||||
* emits {@link Action.OnLoggedIn}, {@link Action.WillStartClient} and {@link Action.StartedClient}.
|
||||
*
|
||||
* @returns true if a session was found; false if no existing session was found.
|
||||
*
|
||||
@@ -787,6 +790,8 @@ async function createOidcTokenRefresher(credentials: IMatrixClientCreds): Promis
|
||||
* optionally clears localstorage, persists new credentials
|
||||
* to localstorage, starts the new client.
|
||||
*
|
||||
* Emits {@link Action.OnLoggedIn}, {@link Action.WillStartClient} and {@link Action.StartedClient}.
|
||||
*
|
||||
* @param {IMatrixClientCreds} credentials The credentials to use
|
||||
* @param {Boolean} clearStorageEnabled True to clear storage before starting the new client
|
||||
* @param {Boolean} isFreshLogin True if this is a fresh login, false if it is previous session being restored
|
||||
@@ -1001,7 +1006,7 @@ export function softLogout(): void {
|
||||
// Ensure that we dispatch a view change **before** stopping the client so
|
||||
// so that React components unmount first. This avoids React soft crashes
|
||||
// that can occur when components try to use a null client.
|
||||
dis.dispatch({ action: "on_client_not_viable" }); // generic version of on_logged_out
|
||||
dis.dispatch({ action: Action.ClientNotViable }); // generic version of on_logged_out
|
||||
stopMatrixClient(/*unsetClient=*/ false);
|
||||
|
||||
// DO NOT CALL LOGOUT. A soft logout preserves data, logout does not.
|
||||
@@ -1019,6 +1024,12 @@ export function isLoggingOut(): boolean {
|
||||
* Starts the matrix client and all other react-sdk services that
|
||||
* listen for events while a session is logged in.
|
||||
*
|
||||
* By the time this method is called, we have successfully logged in if necessary, and the client has been set up with
|
||||
* the access token.
|
||||
*
|
||||
* Emits {@link Acction.WillStartClient} before starting the client, and {@link Action.ClientStarted} when the client has
|
||||
* been started.
|
||||
*
|
||||
* @param client the matrix client to start
|
||||
* @param startSyncing - `true` to actually start syncing the client.
|
||||
* @param clientPegOpts - Options to pass through to {@link MatrixClientPeg.start}.
|
||||
@@ -1034,7 +1045,7 @@ async function startMatrixClient(
|
||||
// to add listeners for the 'sync' event so otherwise we'd have
|
||||
// a race condition (and we need to dispatch synchronously for this
|
||||
// to work).
|
||||
dis.dispatch({ action: "will_start_client" }, true);
|
||||
dis.dispatch({ action: Action.WillStartClient }, true);
|
||||
|
||||
// reset things first just in case
|
||||
SdkContextClass.instance.typingStore.reset();
|
||||
@@ -1080,7 +1091,7 @@ async function startMatrixClient(
|
||||
|
||||
// dispatch that we finished starting up to wire up any other bits
|
||||
// of the matrix client that cannot be set prior to starting up.
|
||||
dis.dispatch({ action: "client_started" });
|
||||
dis.dispatch({ action: Action.ClientStarted });
|
||||
|
||||
if (isSoftLogout()) {
|
||||
softLogout();
|
||||
|
||||
@@ -15,6 +15,7 @@ import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
import dis from "./dispatcher/dispatcher";
|
||||
import Timer from "./utils/Timer";
|
||||
import { type ActionPayload } from "./dispatcher/payloads";
|
||||
import { Action } from "./dispatcher/actions.ts";
|
||||
|
||||
// Time in ms after that a user is considered as unavailable/away
|
||||
const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins
|
||||
@@ -61,7 +62,7 @@ class Presence {
|
||||
}
|
||||
|
||||
private onAction = (payload: ActionPayload): void => {
|
||||
if (payload.action === "user_activity") {
|
||||
if (payload.action === Action.UserActivity) {
|
||||
this.setState(SetPresence.Online);
|
||||
this.unavailableTimer?.restart();
|
||||
}
|
||||
|
||||
@@ -939,7 +939,13 @@ for (const evType of ElementCallEventType.names) {
|
||||
*/
|
||||
export function hasText(ev: MatrixEvent, client: MatrixClient, showHiddenEvents?: boolean): boolean {
|
||||
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
|
||||
return Boolean(handler?.(ev, client, false, showHiddenEvents));
|
||||
try {
|
||||
return Boolean(handler?.(ev, client, false, showHiddenEvents));
|
||||
} catch (e) {
|
||||
console.error(`Error encountered when trying to render event type=${ev.getType()} id=${ev.getId()}`, e);
|
||||
// Returning true if we have a handler so we can show an error tile rather than no tile at all
|
||||
return !!handler;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import dis from "./dispatcher/dispatcher";
|
||||
import Timer from "./utils/Timer";
|
||||
import { Action } from "./dispatcher/actions.ts";
|
||||
|
||||
// important these are larger than the timeouts of timers
|
||||
// used with UserActivity.timeWhileActive*,
|
||||
@@ -190,11 +191,9 @@ export default class UserActivity {
|
||||
this.lastScreenY = event.screenY;
|
||||
}
|
||||
|
||||
dis.dispatch({ action: "user_activity" });
|
||||
dis.dispatch({ action: Action.UserActivity });
|
||||
if (!this.activeNowTimeout.isRunning()) {
|
||||
this.activeNowTimeout.start();
|
||||
dis.dispatch({ action: "user_activity_start" });
|
||||
|
||||
UserActivity.runTimersUntilTimeout(this.attachedActiveNowTimers, this.activeNowTimeout);
|
||||
} else {
|
||||
this.activeNowTimeout.restart();
|
||||
|
||||
71
src/Views.ts
@@ -6,7 +6,76 @@ 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.
|
||||
*/
|
||||
|
||||
/** constants for MatrixChat.state.view */
|
||||
/**
|
||||
* Constants for MatrixChat.state.view.
|
||||
*
|
||||
* The `View` is the primary state machine of the application: it has different states for the various setup flows
|
||||
* that the user may find themselves in. Once we have a functioning client, we can transition to the `LOGGED_IN` state
|
||||
* which is the "normal" state of the application.
|
||||
*
|
||||
* An incomplete state transition diagram follows.
|
||||
*
|
||||
* (initial state)
|
||||
* ┌─────────────────┐ Lock held by other instance ┌─────────────────┐
|
||||
* │ LOADING │─────────────────────────────►│ CONFIRM_LOCK_ │
|
||||
* │ │◄─────────────────────────────│ THEFT │
|
||||
* └─────────────────┘ Lock theft confirmed └─────────────────┘
|
||||
* Session recovered │ │ │
|
||||
* ┌──────────────┘ │ └────────────────┐
|
||||
* │ ┌─────────────┘ │ No previous session
|
||||
* │ │ Token/OIDC login succeeded │
|
||||
* │ │ ▼
|
||||
* │ │ ┌─────────────────┐
|
||||
* │ │ │ WELCOME │ (from all other states
|
||||
* │ │ │ │ except LOCK_STOLEN)
|
||||
* │ │ └─────────────────┘ │
|
||||
* │ │ "Create Account" │ │ "Sign in" │ Client logged out
|
||||
* │ │ ┌────────────────────────┘ │ │
|
||||
* │ │ │ │ ┌────────────────────┘
|
||||
* │ │ │ │ │
|
||||
* │ │ ▼ "Create an ▼ ▼ "Forgot
|
||||
* │ │ ┌─────────────────┐ account" ┌─────────────────┐ password" ┌─────────────────┐
|
||||
* │ │ │ REGISTER │◄───────────────│ LOGIN │───────────────►│ FORGOT_PASSWORD │
|
||||
* │ │ │ │───────────────►│ │◄───────────────│ │
|
||||
* │ │ └─────────────────┘ "Sign in here" └─────────────────┘ Complete / └─────────────────┘
|
||||
* │ │ │ │ "Sign in instead" ▲
|
||||
* │ │ └────────────────────────────────┐ │ │
|
||||
* │ └────────────────────────────────────────┐ │ │ │
|
||||
* │ ▼ ▼ ▼ │
|
||||
* │ ┌──────────────────┐ │
|
||||
* │ │ (postLoginSetup) │ │
|
||||
* │ └──────────────────┘ │
|
||||
* │ ┌────────────────────────────────────┘ │ │ │
|
||||
* │ │ E2EE not enabled ┌─────────────┘ └──────┐ │
|
||||
* │ │ │ Account has │ Account lacks │
|
||||
* │ │ │ cross-signing │ cross-signing │
|
||||
* │ │ │ keys │ keys │
|
||||
* │ │ Client started and ▼ ▼ │
|
||||
* │ │ force_verification ┌─────────────────┐ ┌─────────────────┐ │
|
||||
* │ │ pending │ COMPLETE_ │ │ E2E_SETUP │ │
|
||||
* │ │ ┌─────────────────►│ SECURITY │ │ │ │
|
||||
* │ │ │ └─────────────────┘ └─────────────────┘ │ "Forgotten
|
||||
* │ │ │ ┌───────────────────────┘ │ │ your
|
||||
* │ │ │ │ ┌───────────────────────────────────────────────┘ │ password?"
|
||||
* │ │ │ │ │ │
|
||||
* │ │ │ │ │ (from all other states │
|
||||
* │ │ │ │ │ except LOCK_STOLEN) │
|
||||
* │ │ │ │ │ └──────────────┐ │
|
||||
* ▼ ▼ │ ▼ ▼ Soft logout error ▼ │
|
||||
* ┌─────────────────┐ ┌─────────────────┐
|
||||
* │ LOGGED_IN │ Re-authentication succeeded │ SOFT_LOGOUT │
|
||||
* │ │◄────────────────────────────────────────────────────────│ │
|
||||
* └─────────────────┘ └─────────────────┘
|
||||
*
|
||||
* (from all other states)
|
||||
* │
|
||||
* │ Session lock stolen
|
||||
* ▼
|
||||
* ┌─────────────────┐
|
||||
* │ LOCK_STOLEN │
|
||||
* │ │
|
||||
* └─────────────────┘
|
||||
*/
|
||||
enum Views {
|
||||
// a special initial state which is only used at startup, while we are
|
||||
// trying to re-animate a matrix client or register as a guest.
|
||||
|
||||
@@ -18,6 +18,7 @@ import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import { type ActionPayload } from "../../dispatcher/payloads";
|
||||
import { Action } from "../../dispatcher/actions.ts";
|
||||
|
||||
interface IProps {
|
||||
// URL to request embedded page content from
|
||||
@@ -109,7 +110,7 @@ export default class EmbeddedPage extends React.PureComponent<IProps, IState> {
|
||||
|
||||
private onAction = (payload: ActionPayload): void => {
|
||||
// HACK: Workaround for the context's MatrixClient not being set up at render time.
|
||||
if (payload.action === "client_started") {
|
||||
if (payload.action === Action.ClientStarted) {
|
||||
this.forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -202,7 +202,10 @@ interface IState {
|
||||
hideToSRUsers: boolean;
|
||||
syncError: Error | null;
|
||||
serverConfig?: ValidatedServerConfig;
|
||||
|
||||
/** Has our MatrixClient started? */
|
||||
ready: boolean;
|
||||
|
||||
threepidInvite?: IThreepidInvite;
|
||||
roomOobData?: object;
|
||||
pendingInitialSync?: boolean;
|
||||
@@ -225,7 +228,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
private firstSyncPromise: PromiseWithResolvers<void>;
|
||||
|
||||
private screenAfterLogin?: IScreen;
|
||||
|
||||
/** True if we have successfully completed an OIDC or token login.
|
||||
*
|
||||
* XXX it's unclear if this is ever cleared, so what happens if the user logs out and then logs back in?
|
||||
*/
|
||||
private tokenLogin?: boolean;
|
||||
|
||||
// What to focus on next component update, if anything
|
||||
private focusNext: FocusNextType;
|
||||
private subTitleStatus: string;
|
||||
@@ -386,6 +395,26 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
await Lifecycle.onSessionLockStolen();
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform actions that are specific to a user that has just logged in (compare {@link onLoggedIn}, which, despite
|
||||
* its name, is called when an already-logged-in client is restored at session startup).
|
||||
*
|
||||
* Called when:
|
||||
*
|
||||
* - We successfully completed an OIDC or token login, via {@link initSession}.
|
||||
* - The {@link Login} or {@link Register} components notify us that we successfully completed a non-OIDC login or
|
||||
* registration.
|
||||
*
|
||||
* In both cases, {@link Action.OnLoggedIn} will already have been emitted, but the call to {@link onLoggedIn} will
|
||||
* have been suppressed (by either {@link tokenLogin} being set, or the view being set to {@link Views.LOGIN} or
|
||||
* {@link Views.REGISTER}).
|
||||
*
|
||||
* {@link onWillStartClient} and {@link onClientStarted} will already have been called (but not necessarily
|
||||
* completed).
|
||||
*
|
||||
* This method either calls {@link onLiggedIn} directly, or switches to {@link Views.E2E_SETUP} or
|
||||
* {@link Views.COMPLETE_SECURITY}, which will later call {@link onCompleteSecurityE2eSetupFinished}.
|
||||
*/
|
||||
private async postLoginSetup(): Promise<void> {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const cryptoEnabled = Boolean(cli.getCrypto());
|
||||
@@ -427,10 +456,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
} else {
|
||||
this.setStateForNewView({ view: Views.COMPLETE_SECURITY });
|
||||
}
|
||||
} else if (
|
||||
(await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) &&
|
||||
!(await shouldSkipSetupEncryption(cli))
|
||||
) {
|
||||
} else if (!(await shouldSkipSetupEncryption(cli))) {
|
||||
// if cross-signing is not yet set up, do so now if possible.
|
||||
InitialCryptoSetupStore.sharedInstance().startInitialCryptoSetup(
|
||||
cli,
|
||||
@@ -606,6 +632,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
);
|
||||
}
|
||||
|
||||
private isLoggedInViewPageDisplayed(): boolean {
|
||||
return this.loggedInView.current !== null && this.state.page_type !== undefined;
|
||||
}
|
||||
|
||||
private setStateForNewView(state: Partial<IState>): void {
|
||||
if (state.view === undefined) {
|
||||
throw new Error("setStateForNewView with no view!");
|
||||
@@ -815,13 +845,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "view_last_screen":
|
||||
// This function does what we want, despite the name. The idea is that it shows
|
||||
// the last room we were looking at or some reasonable default/guess. We don't
|
||||
// have to worry about email invites or similar being re-triggered because the
|
||||
// function will have cleared that state and not execute that path.
|
||||
this.showScreenAfterLogin();
|
||||
break;
|
||||
case "hide_left_panel":
|
||||
this.setState(
|
||||
{
|
||||
@@ -859,13 +882,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
this.onLoggedIn();
|
||||
}
|
||||
break;
|
||||
case "on_client_not_viable":
|
||||
case Action.ClientNotViable:
|
||||
this.onSoftLogout();
|
||||
break;
|
||||
case Action.OnLoggedOut:
|
||||
this.onLoggedOut();
|
||||
break;
|
||||
case "will_start_client":
|
||||
case Action.WillStartClient:
|
||||
this.setState({ ready: false }, () => {
|
||||
// if the client is about to start, we are, by definition, not ready.
|
||||
// Set ready to false now, then it'll be set to true when the sync
|
||||
@@ -873,7 +896,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
this.onWillStartClient();
|
||||
});
|
||||
break;
|
||||
case "client_started":
|
||||
case Action.ClientStarted:
|
||||
// No need to make this handler async to wait for the result of this
|
||||
this.onClientStarted().catch((e) => {
|
||||
logger.error("Exception in onClientStarted", e);
|
||||
@@ -1078,7 +1101,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
this.viewWelcome();
|
||||
return;
|
||||
}
|
||||
if (!this.state.currentRoomId && !this.state.currentUserId) {
|
||||
|
||||
if (!this.state.currentRoomId && !this.state.currentUserId && !this.isLoggedInViewPageDisplayed()) {
|
||||
this.viewHome();
|
||||
}
|
||||
}
|
||||
@@ -1379,7 +1403,15 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a new logged in session has started
|
||||
* Called when a new logged in session has started.
|
||||
*
|
||||
* Called:
|
||||
*
|
||||
* - on {@link Action.OnLoggedIn}, but only when we don't expect a separate call to {@link postLoginSetup}.
|
||||
* - from {@link postLoginSetup}, when we don't have crypto setup tasks to perform after the login.
|
||||
*
|
||||
* It's never actually called if we have crypto setup tasks to perform after login (which we normally do, unless
|
||||
* crypto is disabled.) XXX: is this a bug or a feature?
|
||||
*/
|
||||
private async onLoggedIn(): Promise<void> {
|
||||
ThemeController.isLogin = false;
|
||||
@@ -1389,6 +1421,16 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
await this.onShowPostLoginScreen();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the first screen after the application is successfully loaded in a logged-in state.
|
||||
*
|
||||
* Called:
|
||||
*
|
||||
* - by {@link onLoggedIn}
|
||||
* - by {@link onCompleteSecurityE2eSetupFinished}
|
||||
*
|
||||
* In other words, whenever we think we have completed the login and E2E setup tasks.
|
||||
*/
|
||||
private async onShowPostLoginScreen(): Promise<void> {
|
||||
this.setStateForNewView({ view: Views.LOGGED_IN });
|
||||
// If a specific screen is set to be shown after login, show that above
|
||||
@@ -1815,7 +1857,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
// 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) {
|
||||
if (!this.state.currentRoomId && !this.state.currentUserId && !this.isLoggedInViewPageDisplayed()) {
|
||||
this.viewHome();
|
||||
}
|
||||
} else if (screen === "settings") {
|
||||
@@ -2053,7 +2095,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
PerformanceMonitor.instance.stop(PerformanceEntryNames.REGISTER);
|
||||
};
|
||||
|
||||
// complete security / e2e setup has finished
|
||||
/** Called when {@link Views.E2E_SETUP} or {@link Views.COMPLETE_SECURITY} have completed. */
|
||||
private onCompleteSecurityE2eSetupFinished = async (): Promise<void> => {
|
||||
const forceVerify = await this.shouldForceVerification();
|
||||
if (forceVerify) {
|
||||
@@ -2104,7 +2146,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
} else if (this.state.view === Views.COMPLETE_SECURITY) {
|
||||
view = <CompleteSecurity onFinished={this.onCompleteSecurityE2eSetupFinished} />;
|
||||
} else if (this.state.view === Views.E2E_SETUP) {
|
||||
view = <E2eSetup onFinished={this.onCompleteSecurityE2eSetupFinished} />;
|
||||
view = <E2eSetup onCancelled={this.onCompleteSecurityE2eSetupFinished} />;
|
||||
} else if (this.state.view === Views.LOGGED_IN) {
|
||||
// `ready` and `view==LOGGED_IN` may be set before `page_type` (because the
|
||||
// latter is set via the dispatcher). If we don't yet have a `page_type`,
|
||||
|
||||
@@ -175,6 +175,16 @@ interface IRoomProps extends RoomViewProps {
|
||||
* If true, hide the composer
|
||||
*/
|
||||
hideComposer?: boolean;
|
||||
|
||||
/*
|
||||
* If true, hide the right panel
|
||||
*/
|
||||
hideRightPanel?: boolean;
|
||||
|
||||
/**
|
||||
* If true, hide the pinned messages banner
|
||||
*/
|
||||
hidePinnedMessageBanner?: boolean;
|
||||
}
|
||||
|
||||
export { MainSplitContentType };
|
||||
@@ -1197,7 +1207,13 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
case Action.EditEvent: {
|
||||
// Quit early if we're trying to edit events in wrong rendering context
|
||||
if (payload.timelineRenderingType !== this.state.timelineRenderingType) return;
|
||||
if (payload.event && payload.event.getRoomId() !== this.state.roomId) {
|
||||
|
||||
const roomId: string | undefined = payload.event?.getRoomId();
|
||||
|
||||
if (payload.event && roomId !== this.state.roomId) {
|
||||
// if the room is displayed in a module, we don't want to change the room view
|
||||
if (roomId && this.roomViewStore.isRoomDisplayedInModule(roomId)) return;
|
||||
|
||||
// If the event is in a different room (e.g. because the event to be edited is being displayed
|
||||
// in the results of an all-rooms search), we need to view that room first.
|
||||
defaultDispatcher.dispatch<ViewRoomPayload>({
|
||||
@@ -2459,7 +2475,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
</AuxPanel>
|
||||
);
|
||||
|
||||
const pinnedMessageBanner = (
|
||||
const pinnedMessageBanner = !this.props.hidePinnedMessageBanner && (
|
||||
<PinnedMessageBanner room={this.state.room} permalinkCreator={this.permalinkCreator} />
|
||||
);
|
||||
|
||||
@@ -2557,7 +2573,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
);
|
||||
}
|
||||
|
||||
const showRightPanel = !isRoomEncryptionLoading && this.state.room && this.state.showRightPanel;
|
||||
const showRightPanel =
|
||||
!this.props.hideRightPanel && !isRoomEncryptionLoading && this.state.room && this.state.showRightPanel;
|
||||
|
||||
const rightPanel = showRightPanel ? (
|
||||
<RightPanel
|
||||
|
||||
@@ -13,15 +13,19 @@ import CompleteSecurityBody from "../../views/auth/CompleteSecurityBody";
|
||||
import { InitialCryptoSetupDialog } from "../../views/dialogs/security/InitialCryptoSetupDialog";
|
||||
|
||||
interface IProps {
|
||||
onFinished: () => void;
|
||||
/** Callback which is called if the crypto setup failed, and the user clicked the 'cancel' button */
|
||||
onCancelled: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* An {@link AuthPage} which shows the {@link InitialCryptoSetupDialog}.
|
||||
*/
|
||||
export default class E2eSetup extends React.Component<IProps> {
|
||||
public render(): React.ReactNode {
|
||||
return (
|
||||
<AuthPage>
|
||||
<CompleteSecurityBody>
|
||||
<InitialCryptoSetupDialog onFinished={this.props.onFinished} />
|
||||
<InitialCryptoSetupDialog onCancelled={this.props.onCancelled} />
|
||||
</CompleteSecurityBody>
|
||||
</AuthPage>
|
||||
);
|
||||
|
||||
@@ -29,16 +29,6 @@ export interface UserInfoVerificationSectionState {
|
||||
verifySelectedUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const useHomeserverSupportsCrossSigning = (cli: MatrixClient): boolean => {
|
||||
return useAsyncMemo<boolean>(
|
||||
async () => {
|
||||
return cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing");
|
||||
},
|
||||
[cli],
|
||||
false,
|
||||
);
|
||||
};
|
||||
|
||||
const useHasCrossSigningKeys = (cli: MatrixClient, member: User, canVerify: boolean): boolean | undefined => {
|
||||
return useAsyncMemo(async () => {
|
||||
if (!canVerify) return undefined;
|
||||
@@ -56,8 +46,6 @@ export const useUserInfoVerificationViewModel = (
|
||||
): UserInfoVerificationSectionState => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
const homeserverSupportsCrossSigning = useHomeserverSupportsCrossSigning(cli);
|
||||
|
||||
const userTrust = useAsyncMemo<UserVerificationStatus | undefined>(
|
||||
async () => cli.getCrypto()?.getUserVerificationStatus(member.userId),
|
||||
[member.userId],
|
||||
@@ -67,13 +55,7 @@ export const useUserInfoVerificationViewModel = (
|
||||
const hasUserVerificationStatus = Boolean(userTrust);
|
||||
const isUserVerified = Boolean(userTrust?.isVerified());
|
||||
const isMe = member.userId === cli.getUserId();
|
||||
const canVerify =
|
||||
hasUserVerificationStatus &&
|
||||
homeserverSupportsCrossSigning &&
|
||||
!isUserVerified &&
|
||||
!isMe &&
|
||||
devices &&
|
||||
devices.length > 0;
|
||||
const canVerify = hasUserVerificationStatus && !isUserVerified && !isMe && devices && devices.length > 0;
|
||||
|
||||
const hasCrossSigningKeys = useHasCrossSigningKeys(cli, member as User, canVerify);
|
||||
const verifySelectedUser = (): Promise<void> => verifyUser(cli, member as User);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||
|
||||
import dispatcher from "../../../dispatcher/dispatcher";
|
||||
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
@@ -19,7 +20,7 @@ import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useEventEmitter, useEventEmitterState, useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
import { DefaultTagID } from "../../../stores/room-list/models";
|
||||
import { useCall, useConnectionState, useParticipantCount } from "../../../hooks/useCall";
|
||||
import { type ConnectionState } from "../../../models/Call";
|
||||
import { CallEvent, type ConnectionState } from "../../../models/Call";
|
||||
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
|
||||
@@ -67,6 +68,10 @@ export interface RoomListItemViewState {
|
||||
* Whether there are participants in the call.
|
||||
*/
|
||||
hasParticipantInCall: boolean;
|
||||
/**
|
||||
* Whether the call is a voice or video call.
|
||||
*/
|
||||
callType: CallType | undefined;
|
||||
/**
|
||||
* Pre-rendered and translated preview for the latest message in the room, or undefined
|
||||
* if no preview should be shown.
|
||||
@@ -123,10 +128,10 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
|
||||
// EC video call or video room
|
||||
const call = useCall(room.roomId);
|
||||
const connectionState = useConnectionState(call);
|
||||
const hasParticipantInCall = useParticipantCount(call) > 0;
|
||||
const participantCount = useParticipantCount(call);
|
||||
const callConnectionState = call ? connectionState : null;
|
||||
|
||||
const showNotificationDecoration = hasVisibleNotification || hasParticipantInCall;
|
||||
const showNotificationDecoration = hasVisibleNotification || participantCount > 0;
|
||||
|
||||
// Actions
|
||||
|
||||
@@ -138,6 +143,9 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
|
||||
});
|
||||
}, [room]);
|
||||
|
||||
const [callType, setCallType] = useState<CallType>(CallType.Video);
|
||||
useTypedEventEmitter(call ?? undefined, CallEvent.CallTypeChanged, setCallType);
|
||||
|
||||
return {
|
||||
name,
|
||||
notificationState,
|
||||
@@ -148,9 +156,10 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
|
||||
isBold,
|
||||
isVideoRoom,
|
||||
callConnectionState,
|
||||
hasParticipantInCall,
|
||||
hasParticipantInCall: participantCount > 0,
|
||||
messagePreview,
|
||||
showNotificationDecoration,
|
||||
callType: call ? callType : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
|
||||
import DeviceListener, { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../../../DeviceListener";
|
||||
import { useEventEmitterAsyncState } from "../../../../hooks/useEventEmitter";
|
||||
import { resetKeyBackupAndWait } from "../../../../utils/crypto/resetKeyBackup";
|
||||
|
||||
interface KeyStoragePanelState {
|
||||
/**
|
||||
@@ -75,63 +76,58 @@ export function useKeyStoragePanelViewModel(): KeyStoragePanelState {
|
||||
async (enable: boolean) => {
|
||||
setPendingValue(enable);
|
||||
try {
|
||||
// stop the device listener since enabling or (especially) disabling key storage must be
|
||||
// pause the device listener since enabling or (especially) disabling key storage must be
|
||||
// done with a sequence of API calls that will put the account in a slightly different
|
||||
// state each time, so suppress any warning toasts until the process is finished (when
|
||||
// we'll turn it back on again.)
|
||||
DeviceListener.sharedInstance().stop();
|
||||
|
||||
const crypto = matrixClient.getCrypto();
|
||||
if (!crypto) {
|
||||
logger.error("Can't change key backup status: no crypto module available");
|
||||
return;
|
||||
}
|
||||
if (enable) {
|
||||
const childLogger = logger.getChild("[enable key storage]");
|
||||
childLogger.info("User requested enabling key storage");
|
||||
let currentKeyBackup = await crypto.checkKeyBackupAndEnable();
|
||||
if (currentKeyBackup) {
|
||||
logger.info(
|
||||
`Existing key backup is present. version: ${currentKeyBackup.backupInfo.version}`,
|
||||
currentKeyBackup.trustInfo,
|
||||
);
|
||||
// Check if the current key backup can be used. Either of these properties causes the key backup to be used.
|
||||
if (currentKeyBackup.trustInfo.trusted || currentKeyBackup.trustInfo.matchesDecryptionKey) {
|
||||
logger.info("Existing key backup can be used");
|
||||
// state each time, so suppress any warning toasts until the process is finished
|
||||
await DeviceListener.sharedInstance().whilePaused(async () => {
|
||||
const crypto = matrixClient.getCrypto();
|
||||
if (!crypto) {
|
||||
logger.error("Can't change key backup status: no crypto module available");
|
||||
return;
|
||||
}
|
||||
if (enable) {
|
||||
const childLogger = logger.getChild("[enable key storage]");
|
||||
childLogger.info("User requested enabling key storage");
|
||||
let currentKeyBackup = await crypto.checkKeyBackupAndEnable();
|
||||
if (currentKeyBackup) {
|
||||
logger.info(
|
||||
`Existing key backup is present. version: ${currentKeyBackup.backupInfo.version}`,
|
||||
currentKeyBackup.trustInfo,
|
||||
);
|
||||
// Check if the current key backup can be used. Either of these properties causes the key backup to be used.
|
||||
if (currentKeyBackup.trustInfo.trusted || currentKeyBackup.trustInfo.matchesDecryptionKey) {
|
||||
logger.info("Existing key backup can be used");
|
||||
} else {
|
||||
logger.warn("Existing key backup cannot be used, creating new backup");
|
||||
// There aren't any *usable* backups, so we need to create a new one.
|
||||
currentKeyBackup = null;
|
||||
}
|
||||
} else {
|
||||
logger.warn("Existing key backup cannot be used, creating new backup");
|
||||
// There aren't any *usable* backups, so we need to create a new one.
|
||||
currentKeyBackup = null;
|
||||
logger.info("No existing key backup versions are present, creating new backup");
|
||||
}
|
||||
|
||||
// If there is no usable key backup on the server, create one.
|
||||
// `resetKeyBackup` will delete any existing backup, so we only do this if there is no usable backup.
|
||||
if (currentKeyBackup === null) {
|
||||
await resetKeyBackupAndWait(crypto);
|
||||
}
|
||||
|
||||
// Set the flag so that EX no longer thinks the user wants backup disabled
|
||||
await matrixClient.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: false });
|
||||
} else {
|
||||
logger.info("No existing key backup versions are present, creating new backup");
|
||||
logger.info("User requested disabling key backup");
|
||||
// This method will delete the key backup as well as server side recovery keys and other
|
||||
// server-side crypto data.
|
||||
await crypto.disableKeyStorage();
|
||||
|
||||
// Set a flag to say that the user doesn't want key backup.
|
||||
// Element X uses this to determine whether to set up automatically,
|
||||
// so this will stop EX turning it back on spontaneously.
|
||||
await matrixClient.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true });
|
||||
}
|
||||
|
||||
// If there is no usable key backup on the server, create one.
|
||||
// `resetKeyBackup` will delete any existing backup, so we only do this if there is no usable backup.
|
||||
if (currentKeyBackup === null) {
|
||||
await crypto.resetKeyBackup();
|
||||
// resetKeyBackup fires this off in the background without waiting, so we need to do it
|
||||
// explicitly and wait for it, otherwise it won't be enabled yet when we check again.
|
||||
await crypto.checkKeyBackupAndEnable();
|
||||
}
|
||||
|
||||
// Set the flag so that EX no longer thinks the user wants backup disabled
|
||||
await matrixClient.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: false });
|
||||
} else {
|
||||
logger.info("User requested disabling key backup");
|
||||
// This method will delete the key backup as well as server side recovery keys and other
|
||||
// server-side crypto data.
|
||||
await crypto.disableKeyStorage();
|
||||
|
||||
// Set a flag to say that the user doesn't want key backup.
|
||||
// Element X uses this to determine whether to set up automatically,
|
||||
// so this will stop EX turning it back on spontaneously.
|
||||
await matrixClient.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true });
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
setPendingValue(undefined);
|
||||
DeviceListener.sharedInstance().start(matrixClient);
|
||||
}
|
||||
},
|
||||
[setPendingValue, matrixClient],
|
||||
|
||||
@@ -14,5 +14,5 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function AuthBody({ flex, className, children }: PropsWithChildren<Props>): JSX.Element {
|
||||
return <main className={classNames("mx_AuthBody", className, { mx_AuthBody_flex: flex })}>{children}</main>;
|
||||
return <div className={classNames("mx_AuthBody", className, { mx_AuthBody_flex: flex })}>{children}</div>;
|
||||
}
|
||||
|
||||
@@ -89,9 +89,14 @@ export default class AuthPage extends React.PureComponent<React.PropsWithChildre
|
||||
<div className="mx_AuthPage" style={pageStyle}>
|
||||
<div className={modalClasses} style={modalStyle}>
|
||||
{modalBlur}
|
||||
<div className="mx_AuthPage_modalContent" style={modalContentStyle}>
|
||||
<main
|
||||
className="mx_AuthPage_modalContent"
|
||||
style={modalContentStyle}
|
||||
tabIndex={-1}
|
||||
aria-live="polite"
|
||||
>
|
||||
{this.props.children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<AuthFooter />
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ export default class Welcome extends React.PureComponent<EmptyObject> {
|
||||
}
|
||||
|
||||
const replaceMap: Record<string, string> = {
|
||||
"$brand": SdkConfig.get("brand"),
|
||||
"$riot:ssoUrl": "#/start_sso",
|
||||
"$riot:casUrl": "#/start_cas",
|
||||
"$matrixLogo": MATRIX_LOGO_HTML,
|
||||
|
||||
@@ -16,23 +16,21 @@ import Spinner from "../../elements/Spinner";
|
||||
import { InitialCryptoSetupStore, useInitialCryptoSetupStatus } from "../../../../stores/InitialCryptoSetupStore";
|
||||
|
||||
interface Props {
|
||||
onFinished: (success?: boolean) => void;
|
||||
/** Callback which is called if the crypto setup failed, and the user clicked the 'cancel' button */
|
||||
onCancelled: () => void;
|
||||
}
|
||||
|
||||
/*
|
||||
* Walks the user through the process of creating a cross-signing keys.
|
||||
/**
|
||||
* Walks the user through the process of creating cross-signing keys.
|
||||
*
|
||||
* In most cases, only a spinner is shown, but for more
|
||||
* complex auth like SSO, the user may need to complete some steps to proceed.
|
||||
*/
|
||||
export const InitialCryptoSetupDialog: React.FC<Props> = ({ onFinished }) => {
|
||||
export const InitialCryptoSetupDialog: React.FC<Props> = ({ onCancelled }) => {
|
||||
const onRetryClick = useCallback(() => {
|
||||
InitialCryptoSetupStore.sharedInstance().retry();
|
||||
}, []);
|
||||
|
||||
const onCancelClick = useCallback(() => {
|
||||
onFinished(false);
|
||||
}, [onFinished]);
|
||||
|
||||
const status = useInitialCryptoSetupStatus(InitialCryptoSetupStore.sharedInstance());
|
||||
|
||||
let content;
|
||||
@@ -44,7 +42,7 @@ export const InitialCryptoSetupDialog: React.FC<Props> = ({ onFinished }) => {
|
||||
<DialogButtons
|
||||
primaryButton={_t("action|retry")}
|
||||
onPrimaryButtonClick={onRetryClick}
|
||||
onCancel={onCancelClick}
|
||||
onCancel={onCancelled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,7 +58,6 @@ export const InitialCryptoSetupDialog: React.FC<Props> = ({ onFinished }) => {
|
||||
return (
|
||||
<BaseDialog
|
||||
className="mx_CreateCrossSigningDialog"
|
||||
onFinished={onFinished}
|
||||
title={_t("encryption|bootstrap_title")}
|
||||
hasCancel={false}
|
||||
fixedWidth={false}
|
||||
|
||||
@@ -23,7 +23,10 @@ export interface ICategory {
|
||||
id: CategoryKey;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
// Whether the category is currently visible
|
||||
visible: boolean;
|
||||
// Whether the category is the first visible category
|
||||
firstVisible: boolean;
|
||||
ref: RefObject<HTMLButtonElement | null>;
|
||||
}
|
||||
|
||||
|
||||
@@ -79,71 +79,44 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
||||
...DATA_BY_CATEGORY,
|
||||
};
|
||||
|
||||
this.categories = [
|
||||
{
|
||||
id: "recent",
|
||||
name: _t("emoji|category_frequently_used"),
|
||||
enabled: this.recentlyUsed.length > 0,
|
||||
visible: this.recentlyUsed.length > 0,
|
||||
ref: React.createRef(),
|
||||
},
|
||||
{
|
||||
id: "people",
|
||||
name: _t("emoji|category_smileys_people"),
|
||||
enabled: true,
|
||||
visible: true,
|
||||
ref: React.createRef(),
|
||||
},
|
||||
{
|
||||
id: "nature",
|
||||
name: _t("emoji|category_animals_nature"),
|
||||
enabled: true,
|
||||
visible: false,
|
||||
ref: React.createRef(),
|
||||
},
|
||||
{
|
||||
id: "foods",
|
||||
name: _t("emoji|category_food_drink"),
|
||||
enabled: true,
|
||||
visible: false,
|
||||
ref: React.createRef(),
|
||||
},
|
||||
{
|
||||
id: "activity",
|
||||
name: _t("emoji|category_activities"),
|
||||
enabled: true,
|
||||
visible: false,
|
||||
ref: React.createRef(),
|
||||
},
|
||||
{
|
||||
id: "places",
|
||||
name: _t("emoji|category_travel_places"),
|
||||
enabled: true,
|
||||
visible: false,
|
||||
ref: React.createRef(),
|
||||
},
|
||||
{
|
||||
id: "objects",
|
||||
name: _t("emoji|category_objects"),
|
||||
enabled: true,
|
||||
visible: false,
|
||||
ref: React.createRef(),
|
||||
},
|
||||
{
|
||||
id: "symbols",
|
||||
name: _t("emoji|category_symbols"),
|
||||
enabled: true,
|
||||
visible: false,
|
||||
ref: React.createRef(),
|
||||
},
|
||||
{
|
||||
id: "flags",
|
||||
name: _t("emoji|category_flags"),
|
||||
enabled: true,
|
||||
visible: false,
|
||||
ref: React.createRef(),
|
||||
},
|
||||
const hasRecentlyUsed = this.recentlyUsed.length > 0;
|
||||
|
||||
const categoryConfig: Array<{
|
||||
id: CategoryKey;
|
||||
name: string;
|
||||
}> = [
|
||||
{ id: "recent", name: _t("emoji|category_frequently_used") },
|
||||
{ id: "people", name: _t("emoji|category_smileys_people") },
|
||||
{ id: "nature", name: _t("emoji|category_animals_nature") },
|
||||
{ id: "foods", name: _t("emoji|category_food_drink") },
|
||||
{ id: "activity", name: _t("emoji|category_activities") },
|
||||
{ id: "places", name: _t("emoji|category_travel_places") },
|
||||
{ id: "objects", name: _t("emoji|category_objects") },
|
||||
{ id: "symbols", name: _t("emoji|category_symbols") },
|
||||
{ id: "flags", name: _t("emoji|category_flags") },
|
||||
];
|
||||
|
||||
this.categories = categoryConfig.map((config) => {
|
||||
let isEnabled = true;
|
||||
let isVisible = false;
|
||||
let firstVisible = false;
|
||||
if (config.id === "recent") {
|
||||
isEnabled = hasRecentlyUsed;
|
||||
isVisible = hasRecentlyUsed;
|
||||
firstVisible = hasRecentlyUsed;
|
||||
} else if (config.id === "people") {
|
||||
isVisible = true;
|
||||
firstVisible = !hasRecentlyUsed;
|
||||
}
|
||||
return {
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
enabled: isEnabled,
|
||||
visible: isVisible,
|
||||
firstVisible: firstVisible,
|
||||
ref: React.createRef(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private onScroll = (): void => {
|
||||
@@ -259,6 +232,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
||||
const body = this.scrollRef.current?.containerRef.current;
|
||||
if (!body) return;
|
||||
const rect = body.getBoundingClientRect();
|
||||
let firstVisibleFound = false;
|
||||
for (const cat of this.categories) {
|
||||
const elem = body.querySelector(`[data-category-id="${cat.id}"]`);
|
||||
if (!elem) {
|
||||
@@ -270,15 +244,24 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
||||
const y = elemRect.y - rect.y;
|
||||
const yEnd = elemRect.y + elemRect.height - rect.y;
|
||||
cat.visible = y < rect.height && yEnd > 0;
|
||||
if (cat.visible && !firstVisibleFound) {
|
||||
firstVisibleFound = true;
|
||||
cat.firstVisible = true;
|
||||
} else {
|
||||
cat.firstVisible = false;
|
||||
}
|
||||
// We update this here instead of through React to avoid re-render on scroll.
|
||||
if (!cat.ref.current) continue;
|
||||
if (cat.visible) {
|
||||
cat.ref.current.classList.add("mx_EmojiPicker_anchor_visible");
|
||||
cat.ref.current.setAttribute("aria-selected", "true");
|
||||
cat.ref.current.setAttribute("tabindex", "0");
|
||||
} else {
|
||||
cat.ref.current.classList.remove("mx_EmojiPicker_anchor_visible");
|
||||
cat.ref.current.setAttribute("aria-selected", "false");
|
||||
}
|
||||
if (cat.firstVisible) {
|
||||
cat.ref.current.setAttribute("tabindex", "0");
|
||||
} else {
|
||||
cat.ref.current.setAttribute("tabindex", "-1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
import { findLastIndex } from "lodash";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { type CategoryKey, type ICategory } from "./Category";
|
||||
@@ -33,14 +32,8 @@ class Header extends React.PureComponent<IProps> {
|
||||
}
|
||||
|
||||
private changeCategoryRelative(delta: number): void {
|
||||
let current: number;
|
||||
// As multiple categories may be visible at once, we want to find the one closest to the relative direction
|
||||
if (delta < 0) {
|
||||
current = this.props.categories.findIndex((c) => c.visible);
|
||||
} else {
|
||||
// XXX: Switch to Array::findLastIndex once we enable ES2023
|
||||
current = findLastIndex(this.props.categories, (c) => c.visible);
|
||||
}
|
||||
// Move to the next/previous category using the first visible as the current.
|
||||
const current = this.props.categories.findIndex((c) => c.visible);
|
||||
this.changeCategoryAbsolute(current + delta, delta);
|
||||
}
|
||||
|
||||
@@ -104,7 +97,7 @@ class Header extends React.PureComponent<IProps> {
|
||||
onClick={() => this.props.onAnchorClick(category.id)}
|
||||
title={category.name}
|
||||
role="tab"
|
||||
tabIndex={category.visible ? 0 : -1} // roving
|
||||
tabIndex={category.firstVisible ? 0 : -1} // roving
|
||||
aria-selected={category.visible}
|
||||
aria-controls={`mx_EmojiPicker_category_${category.id}`}
|
||||
/>
|
||||
|
||||
@@ -10,12 +10,10 @@ import React, { type FC } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { type Call } from "../../../models/Call";
|
||||
import { useParticipantCount } from "../../../hooks/useCall";
|
||||
|
||||
export enum LiveContentType {
|
||||
Video,
|
||||
// More coming soon
|
||||
Voice,
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -33,6 +31,7 @@ export const LiveContentSummary: FC<Props> = ({ type, text, active, participantC
|
||||
<span
|
||||
className={classNames("mx_LiveContentSummary_text", {
|
||||
mx_LiveContentSummary_text_video: type === LiveContentType.Video,
|
||||
mx_LiveContentSummary_text_voice: type === LiveContentType.Voice,
|
||||
mx_LiveContentSummary_text_active: active,
|
||||
})}
|
||||
>
|
||||
@@ -51,16 +50,3 @@ export const LiveContentSummary: FC<Props> = ({ type, text, active, participantC
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
interface LiveContentSummaryWithCallProps {
|
||||
call: Call;
|
||||
}
|
||||
|
||||
export const LiveContentSummaryWithCall: FC<LiveContentSummaryWithCallProps> = ({ call }) => (
|
||||
<LiveContentSummary
|
||||
type={LiveContentType.Video}
|
||||
text={_t("common|video")}
|
||||
active={false}
|
||||
participantCount={useParticipantCount(call)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,16 @@ interface IProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Height of a single member list item
|
||||
*/
|
||||
const MEMBER_LIST_ITEM_HEIGHT = 56;
|
||||
/**
|
||||
* Amount to extend the top and bottom of the viewport by.
|
||||
* From manual testing 15 items seems to be enough to never really see the blank space when scrolling.
|
||||
*/
|
||||
const EXTENDED_VIEWPORT_HEIGHT = 15 * MEMBER_LIST_ITEM_HEIGHT;
|
||||
|
||||
const MemberListView: React.FC<IProps> = (props: IProps) => {
|
||||
const vm = useMemberListViewModel(props.roomId);
|
||||
const { isPresenceEnabled, memberCount } = vm;
|
||||
@@ -106,6 +116,11 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
|
||||
isItemFocusable={isItemFocusable}
|
||||
role="listbox"
|
||||
aria-label={_t("member_list|list_title")}
|
||||
fixedItemHeight={MEMBER_LIST_ITEM_HEIGHT}
|
||||
increaseViewportBy={{
|
||||
bottom: EXTENDED_VIEWPORT_HEIGHT,
|
||||
top: EXTENDED_VIEWPORT_HEIGHT,
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</BaseCard>
|
||||
|
||||
@@ -12,6 +12,8 @@ import NotificationOffIcon from "@vector-im/compound-design-tokens/assets/web/ic
|
||||
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
|
||||
import EmailIcon from "@vector-im/compound-design-tokens/assets/web/icons/email-solid";
|
||||
import { UnreadCounter, Unread } from "@vector-im/compound-web";
|
||||
import VoiceCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/voice-call-solid";
|
||||
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||
import { Flex } from "@element-hq/web-shared-components";
|
||||
|
||||
import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
|
||||
@@ -24,9 +26,9 @@ interface NotificationDecorationProps extends HTMLProps<HTMLDivElement> {
|
||||
*/
|
||||
notificationState: RoomNotificationState;
|
||||
/**
|
||||
* Whether the room has a video call.
|
||||
* Whether the room has a voice or video call.
|
||||
*/
|
||||
hasVideoCall: boolean;
|
||||
callType?: CallType;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,7 +36,7 @@ interface NotificationDecorationProps extends HTMLProps<HTMLDivElement> {
|
||||
*/
|
||||
export function NotificationDecoration({
|
||||
notificationState,
|
||||
hasVideoCall,
|
||||
callType,
|
||||
...props
|
||||
}: NotificationDecorationProps): JSX.Element | null {
|
||||
// Listen to the notification state and update the component when it changes
|
||||
@@ -58,7 +60,7 @@ export function NotificationDecoration({
|
||||
muted: notificationState.muted,
|
||||
}));
|
||||
|
||||
if (!hasAnyNotificationOrActivity && !muted && !hasVideoCall) return null;
|
||||
if (!hasAnyNotificationOrActivity && !muted && !callType) return null;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@@ -69,7 +71,12 @@ export function NotificationDecoration({
|
||||
data-testid="notification-decoration"
|
||||
>
|
||||
{isUnsentMessage && <ErrorIcon width="20px" height="20px" fill="var(--cpd-color-icon-critical-primary)" />}
|
||||
{hasVideoCall && <VideoCallIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
|
||||
{callType === CallType.Video && (
|
||||
<VideoCallIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
|
||||
)}
|
||||
{callType === CallType.Voice && (
|
||||
<VoiceCallIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
|
||||
)}
|
||||
{invited && <EmailIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
|
||||
{isMention && <MentionIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
|
||||
{(isMention || isNotification) && <UnreadCounter count={count || null} />}
|
||||
|
||||
@@ -70,10 +70,10 @@ export function RoomListHeaderView(): JSX.Element {
|
||||
<ComposeMenu vm={vm} />
|
||||
) : (
|
||||
<IconButton
|
||||
aria-label={_t("action|start_chat")}
|
||||
onClick={(e) => vm.createChatRoom(e.nativeEvent)}
|
||||
tooltip={_t("action|new_conversation")}
|
||||
>
|
||||
<ComposeIcon color="var(--cpd-color-icon-secondary)" />
|
||||
<ComposeIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
@@ -163,8 +163,8 @@ function ComposeMenu({ vm }: ComposeMenuProps): JSX.Element {
|
||||
side="right"
|
||||
align="start"
|
||||
trigger={
|
||||
<IconButton aria-label={_t("action|add")}>
|
||||
<ComposeIcon color="var(--cpd-color-icon-secondary)" />
|
||||
<IconButton tooltip={_t("action|new_conversation")}>
|
||||
<ComposeIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -132,7 +132,7 @@ export const RoomListItemView = memo(function RoomListItemView({
|
||||
<NotificationDecoration
|
||||
notificationState={vm.notificationState}
|
||||
aria-hidden={true}
|
||||
hasVideoCall={vm.hasParticipantInCall}
|
||||
callType={vm.callType}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Element Creations Ltd.
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
@@ -29,7 +30,8 @@ import { initialiseDehydrationIfEnabled } from "../../../../utils/device/dehydra
|
||||
import { withSecretStorageKeyCache } from "../../../../SecurityManager";
|
||||
import { EncryptionCardButtons } from "./EncryptionCardButtons";
|
||||
import { logErrorAndShowErrorDialog } from "../../../../utils/ErrorUtils.tsx";
|
||||
import { RECOVERY_ACCOUNT_DATA_KEY } from "../../../../DeviceListener";
|
||||
import DeviceListener, { RECOVERY_ACCOUNT_DATA_KEY } from "../../../../DeviceListener";
|
||||
import { resetKeyBackupAndWait } from "../../../../utils/crypto/resetKeyBackup";
|
||||
|
||||
/**
|
||||
* The possible states of the component.
|
||||
@@ -123,14 +125,27 @@ export function ChangeRecoveryKey({
|
||||
if (!crypto) return onFinish();
|
||||
|
||||
try {
|
||||
// We need to enable the cache to avoid to prompt the user to enter the new key
|
||||
// when we will try to access the secret storage during the bootstrap
|
||||
await withSecretStorageKeyCache(async () => {
|
||||
await crypto.bootstrapSecretStorage({
|
||||
setupNewSecretStorage: true,
|
||||
createSecretStorageKey: async () => recoveryKey,
|
||||
const deviceListener = DeviceListener.sharedInstance();
|
||||
|
||||
// we need to call keyStorageOutOfSyncNeedsBackupReset here because
|
||||
// deviceListener.whilePaused() sets its client to undefined, so
|
||||
// keyStorageOutOfSyncNeedsBackupReset won't be able to check
|
||||
// the backup state.
|
||||
const needsBackupReset = await deviceListener.keyStorageOutOfSyncNeedsBackupReset(true);
|
||||
await deviceListener.whilePaused(async () => {
|
||||
// We need to enable the cache to avoid to prompt the user to enter the new key
|
||||
// when we will try to access the secret storage during the bootstrap
|
||||
await withSecretStorageKeyCache(async () => {
|
||||
await crypto.bootstrapSecretStorage({
|
||||
setupNewSecretStorage: true,
|
||||
createSecretStorageKey: async () => recoveryKey,
|
||||
});
|
||||
// Reset the key backup if needed
|
||||
if (needsBackupReset) {
|
||||
await resetKeyBackupAndWait(crypto);
|
||||
}
|
||||
await initialiseDehydrationIfEnabled(matrixClient, { createNewKey: true });
|
||||
});
|
||||
await initialiseDehydrationIfEnabled(matrixClient, { createNewKey: true });
|
||||
});
|
||||
|
||||
// Record the fact that the user explicitly enabled recovery.
|
||||
|
||||
@@ -121,7 +121,7 @@ export enum Action {
|
||||
UpdateSystemFont = "update_system_font",
|
||||
|
||||
/**
|
||||
* Changes room based on payload parameters. Should be used with JoinRoomPayload.
|
||||
* Changes room based on payload parameters. Should be used with ViewRoomPayload.
|
||||
*/
|
||||
ViewRoom = "view_room",
|
||||
|
||||
@@ -316,16 +316,39 @@ export enum Action {
|
||||
*/
|
||||
ShowRoomTopic = "show_room_topic",
|
||||
|
||||
/**
|
||||
* Fired when the client is no longer viable to use: specifically, that we have been "soft-logged out".
|
||||
*/
|
||||
ClientNotViable = "client_not_viable",
|
||||
|
||||
/**
|
||||
* Fired when the client was logged out. No additional payload information required.
|
||||
*/
|
||||
OnLoggedOut = "on_logged_out",
|
||||
|
||||
/**
|
||||
* Fired when the client was logged in. No additional payload information required.
|
||||
* Fired when the client was logged in, or has otherwise been set up with authentication data (e.g., by loading the
|
||||
* access token from local storage). Note that this does not necessarily mean that a login action has happened,
|
||||
* just that authentication creds have been set up.
|
||||
*
|
||||
* No additional payload information required.
|
||||
*/
|
||||
OnLoggedIn = "on_logged_in",
|
||||
|
||||
/**
|
||||
* Fired when the client is about to be started, shortly after {@link OnLoggedIn}.
|
||||
*
|
||||
* No additional payload information required.
|
||||
*/
|
||||
WillStartClient = "will_start_client",
|
||||
|
||||
/**
|
||||
* Fired when the client has started, shortly after {@link WillStartClient}.
|
||||
*
|
||||
* No additional payload information required.
|
||||
*/
|
||||
ClientStarted = "client_started",
|
||||
|
||||
/**
|
||||
* Overwrites the existing login with fresh session credentials. Use with a OverwriteLoginPayload.
|
||||
*/
|
||||
@@ -380,4 +403,10 @@ export enum Action {
|
||||
* Open the create room dialog
|
||||
*/
|
||||
CreateRoom = "view_create_room",
|
||||
|
||||
/**
|
||||
* The `UserActivity` tracker determined that there was some activity from the user (typically a mouse movement
|
||||
* or keyboard event).
|
||||
*/
|
||||
UserActivity = "user_activity",
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ interface BaseViewRoomPayload extends Pick<ActionPayload, "action"> {
|
||||
clear_search?: boolean; // Whether to clear the room list search
|
||||
view_call?: boolean; // Whether to view the call or call lobby for the room
|
||||
skipLobby?: boolean; // Whether to skip the call lobby when showing the call (only supported for element calls)
|
||||
voiceOnly?: boolean; // Whether the call is voice only (only supported for element calls)
|
||||
opts?: JoinRoomPayload["opts"];
|
||||
|
||||
deferred_action?: ActionPayload; // Action to fire after MatrixChat handles this ViewRoom action
|
||||
|
||||