diff --git a/.eslintrc.js b/.eslintrc.js index 26865d55ec2..9c88bdba9c9 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,6 +1,11 @@ module.exports = { plugins: ["matrix-org", "eslint-plugin-react-compiler"], - extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"], + extends: [ + "plugin:matrix-org/babel", + "plugin:matrix-org/react", + "plugin:matrix-org/a11y", + "plugin:storybook/recommended", + ], parserOptions: { project: ["./tsconfig.json"], }, diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 65de7e02c82..2c848fc747e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,7 +45,7 @@ jobs: run: shell: bash steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 # When a release commit is pushed to master, manage the release branch - name: Check commit message and run release script @@ -60,7 +60,8 @@ jobs: ./scripts/branch-release.sh exit 0 - - uses: actions/setup-node@v4 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: cache: "yarn" node-version: "lts/*" @@ -90,7 +91,7 @@ jobs: # - run: mv dist/elecord-*.tar.gz dist/elecord.tar.gz - name: Upload Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: webapp path: dist/elecord-*.tar.gz diff --git a/.gitignore b/.gitignore index 11a267ca157..0e1287d0511 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,10 @@ electron/pub /index.html # version file and tarball created by `npm pack` / `yarn pack` /git-revision.txt + +*storybook.log +storybook-static + # elecord ignores /test /playwright diff --git a/.stylelintrc.js b/.stylelintrc.js index ffc6c345b96..3244d122c56 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -70,5 +70,13 @@ module.exports = { ], }, ], + "property-no-deprecated": [ + true, + { + ignoreProperties: ["-webkit-box-orient", "word-wrap"], + }, + ], + "nesting-selector-no-missing-scoping-root": null, + "no-invalid-position-declaration": null, }, }; diff --git a/README.md b/README.md index 8ac1708ac39..2a9b360622d 100644 --- a/README.md +++ b/README.md @@ -24,4 +24,4 @@ The [elecord-web](https://github.com/elecordapp/elecord-web) app is © [hazzuk]( The [elecord logo](https://github.com/elecordapp/elecord-web/blob/master/res/vector-icons/1240x600.png) and it's derivatives are © [hazzuk](https://github.com/hazzuk) used under the terms of the elecord [logo license](https://github.com/elecordapp/elecord-web/blob/master/LOGO_LICENSE.txt). -The original [element-web](https://github.com/element-hq/element-web) project source code is © [New Vector Ltd](https://element.io/) and other contributors. Used under the terms of the [AGPL-3.0-only](https://github.com/elecordapp/elecord-web/blob/master/LICENSE-AGPL-3.0) license. \ No newline at end of file +The original [element-web](https://github.com/element-hq/element-web) project source code is © [New Vector Ltd](https://element.io/) and other contributors. Used under the terms of the [AGPL-3.0-only](https://github.com/elecordapp/elecord-web/blob/master/LICENSE-AGPL-3.0) license. diff --git a/config.json b/config.json index d3c2c287019..d40fa93f102 100644 --- a/config.json +++ b/config.json @@ -14,8 +14,7 @@ "https://scalar.vector.im/_matrix/integrations/v1", "https://scalar.vector.im/api", "https://scalar-staging.vector.im/_matrix/integrations/v1", - "https://scalar-staging.vector.im/api", - "https://scalar-staging.riot.im/scalar/api" + "https://scalar-staging.vector.im/api" ], "show_labs_settings": false, "room_directory": { @@ -60,4 +59,4 @@ "url": "https://elecord.app", "url_win64": "https://packages.elecord.app/desktop/install/win32/x64/elecord%20Setup.exe" } -} \ No newline at end of file +} diff --git a/declaration.d.ts b/declaration.d.ts new file mode 100644 index 00000000000..928c567c31e --- /dev/null +++ b/declaration.d.ts @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +declare module "*.module.css"; diff --git a/package.json b/package.json index 18db58165a5..e2a84975cb5 100644 --- a/package.json +++ b/package.json @@ -65,23 +65,28 @@ "coverage": "yarn test --coverage", "analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp", "update:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js", - "postinstall": "patch-package" + "postinstall": "patch-package", + "storybook": "storybook dev -p 6007", + "build-storybook": "storybook build", + "test:storybook": "test-storybook --url http://localhost:6007/", + "test:storybook:ci": "concurrently -k -s first -n \"SB,TEST\" \"yarn storybook --no-open\" \"wait-on tcp:6007 && yarn test-storybook --url http://localhost:6007/ --ci --maxWorkers=2\"", + "test:storybook:update": "playwright-screenshots --entrypoint yarn --with-node-modules && playwright-screenshots --entrypoint /work/node_modules/.bin/test-storybook --with-node-modules --url http://host.docker.internal:6007/ --updateSnapshot" }, "resolutions": { - "**/pretty-format/react-is": "19.1.0", - "@playwright/test": "1.51.1", - "@types/react": "19.1.1", - "@types/react-dom": "19.1.2", - "oidc-client-ts": "3.2.0", + "**/pretty-format/react-is": "19.1.1", + "@playwright/test": "1.54.2", + "@types/react": "19.1.10", + "@types/react-dom": "19.1.7", + "oidc-client-ts": "3.3.0", "jwt-decode": "4.0.0", - "caniuse-lite": "1.0.30001714", - "testcontainers": "10.24.2", + "caniuse-lite": "1.0.30001724", + "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": "^0.1.1", + "@element-hq/element-web-module-api": "1.4.1", "@fontsource/inconsolata": "^5", "@fontsource/inter": "^5", "@formatjs/intl-segmenter": "^11.5.7", @@ -89,12 +94,11 @@ "@matrix-org/emojibase-bindings": "^1.3.4", "@matrix-org/react-sdk-module-api": "^2.4.0", "@matrix-org/spec": "^1.7.0", - "@sentry/browser": "^9.0.0", + "@sentry/browser": "^10.0.0", "@types/png-chunks-extract": "^1.0.2", - "@types/react-virtualized": "^9.21.30", - "@vector-im/compound-design-tokens": "^4.0.0", - "@vector-im/compound-web": "^7.10.2", - "@vector-im/matrix-wysiwyg": "2.38.3", + "@vector-im/compound-design-tokens": "^6.0.0", + "@vector-im/compound-web": "^8.1.2", + "@vector-im/matrix-wysiwyg": "2.39.0", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", @@ -113,7 +117,7 @@ "emojibase-regex": "15.3.2", "escape-html": "^1.0.3", "file-saver": "^2.0.5", - "filesize": "10.1.6", + "filesize": "11.0.2", "github-markdown-css": "^5.5.1", "glob-to-regexp": "^0.4.1", "highlight.js": "^11.3.1", @@ -124,14 +128,14 @@ "jsrsasign": "^11.0.0", "jszip": "^3.7.0", "katex": "^0.16.0", - "linkify-react": "4.2.0", - "linkify-string": "4.2.0", - "linkifyjs": "4.2.0", + "linkify-react": "4.3.2", + "linkify-string": "4.3.2", + "linkifyjs": "4.3.2", "lodash": "^4.17.21", "maplibre-gl": "^5.0.0", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "0.0.1", - "matrix-js-sdk": "37.5.0", + "matrix-js-sdk": "38.2.0", "matrix-widget-api": "^1.10.0", "memoize-one": "^6.0.0", "mime": "^4.0.4", @@ -139,7 +143,7 @@ "opus-recorder": "^8.0.3", "pako": "^2.0.3", "png-chunks-extract": "^1.0.0", - "posthog-js": "1.236.1", + "posthog-js": "1.260.1", "qrcode": "1.5.4", "re-resizable": "6.11.2", "react": "^19.0.0", @@ -149,10 +153,10 @@ "react-focus-lock": "^2.5.1", "react-string-replace": "^1.1.1", "react-transition-group": "^4.4.1", - "react-virtualized": "^9.22.5", + "react-virtuoso": "^4.14.0", "rfc4648": "^1.4.0", "sanitize-filename": "^1.6.3", - "sanitize-html": "2.15.0", + "sanitize-html": "2.17.0", "tar-js": "^0.3.0", "temporal-polyfill": "^0.3.0", "ua-parser-js": "^1.0.2", @@ -181,20 +185,26 @@ "@babel/preset-typescript": "^7.12.7", "@babel/runtime": "^7.12.5", "@casualbot/jest-sonar-reporter": "2.2.7", - "@element-hq/element-call-embedded": "0.9.0", - "@element-hq/element-web-playwright-common": "^1.1.5", + "@element-hq/element-call-embedded": "0.14.1", + "@element-hq/element-web-playwright-common": "^1.4.6", "@peculiar/webcrypto": "^1.4.3", "@playwright/test": "^1.50.1", "@principalstudio/html-webpack-inject-preload": "^1.2.7", "@rrweb/types": "^2.0.0-alpha.18", - "@sentry/webpack-plugin": "^3.0.0", - "@stylistic/eslint-plugin": "^4.0.0", + "@sentry/webpack-plugin": "^4.0.0", + "@storybook/addon-designs": "^10.0.1", + "@storybook/addon-docs": "^9.0.12", + "@storybook/icons": "^1.4.0", + "@storybook/react-vite": "^9.0.15", + "@storybook/test-runner": "^0.23.0", + "@stylistic/eslint-plugin": "^5.0.0", "@svgr/webpack": "^8.0.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", "@types/commonmark": "^0.27.4", + "@types/content-type": "^1.1.9", "@types/counterpart": "^0.18.1", "@types/css-tree": "^2.3.8", "@types/diff-match-patch": "^1.0.32", @@ -213,11 +223,12 @@ "@types/node-fetch": "^2.6.2", "@types/pako": "^2.0.0", "@types/qrcode": "^1.3.5", - "@types/react": "19.1.1", + "@types/react": "19.1.10", "@types/react-beautiful-dnd": "^13.0.0", - "@types/react-dom": "19.1.2", + "@types/react-dom": "19.1.7", "@types/react-transition-group": "^4.4.0", - "@types/sanitize-html": "2.15.0", + "@types/sanitize-html": "2.16.0", + "@types/sdp-transform": "^2.4.10", "@types/semver": "^7.5.8", "@types/tar-js": "^0.3.5", "@types/ua-parser-js": "^0.7.36", @@ -232,10 +243,10 @@ "concurrently": "^9.0.0", "copy-webpack-plugin": "^13.0.0", "core-js": "^3.38.1", - "cronstrue": "^2.41.0", + "cronstrue": "^3.0.0", "css-loader": "^7.0.0", "css-minimizer-webpack-plugin": "^7.0.0", - "dotenv": "^16.0.2", + "dotenv": "^17.0.0", "eslint": "8.57.1", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^10.0.0", @@ -247,6 +258,7 @@ "eslint-plugin-react": "^7.28.0", "eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124", "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-storybook": "^9.0.12", "eslint-plugin-unicorn": "^56.0.0", "express": "^5.0.0", "fake-indexeddb": "^6.0.0", @@ -256,14 +268,16 @@ "glob": "^11.0.0", "html-webpack-plugin": "^5.5.3", "husky": "^9.0.0", + "identity-obj-proxy": "^3.0.0", "jest": "^29.6.2", "jest-canvas-mock": "^2.5.2", "jest-environment-jsdom": "^29.7.0", + "jest-image-snapshot": "^6.5.1", "jest-mock": "^29.6.2", "jest-raw-loader": "^1.0.1", "jsqr": "^1.4.0", "knip": "^5.36.2", - "lint-staged": "^15.0.2", + "lint-staged": "^16.0.0", "matrix-web-i18n": "^3.2.1", "mini-css-extract-plugin": "2.9.2", "minimist": "^1.2.6", @@ -276,26 +290,29 @@ "postcss-hexrgba": "2.1.0", "postcss-import": "16.1.0", "postcss-loader": "8.1.1", - "postcss-mixins": "^11.0.0", + "postcss-mixins": "^12.0.0", "postcss-nested": "^7.0.0", "postcss-preset-env": "^10.0.0", "postcss-scss": "^4.0.4", "postcss-simple-vars": "^7.0.1", - "prettier": "3.5.3", + "prettier": "3.6.2", "process": "^0.11.10", "raw-loader": "^4.0.2", "rimraf": "^6.0.0", "semver": "^7.5.2", "source-map-loader": "^5.0.0", - "stylelint": "^16.13.0", - "stylelint-config-standard": "^38.0.0", + "storybook": "^9.0.12", + "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": "^10.20.0", + "testcontainers": "^11.0.0", "ts-node": "^10.9.1", "typescript": "5.8.3", "util": "^0.12.5", + "vite": "^7.0.1", + "vite-plugin-node-polyfills": "^0.24.0", "web-streams-polyfill": "^4.0.0", "webpack": "^5.89.0", "webpack-bundle-analyzer": "^4.8.0", @@ -312,5 +329,6 @@ }, "engines": { "node": ">=20.0.0" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/patches/@element-hq+element-call-embedded+0.9.0.patch b/patches/@element-hq+element-call-embedded+0.14.1.patch similarity index 83% rename from patches/@element-hq+element-call-embedded+0.9.0.patch rename to patches/@element-hq+element-call-embedded+0.14.1.patch index ab67f31ef05..de10d197d9d 100644 --- a/patches/@element-hq+element-call-embedded+0.9.0.patch +++ b/patches/@element-hq+element-call-embedded+0.14.1.patch @@ -1,7 +1,7 @@ --- a/node_modules/@element-hq/element-call-embedded/dist/index.html +++ b/node_modules/@element-hq/element-call-embedded/dist/index.html @@ -1 +1 @@ --Element Call
+-Element Call
\ No newline at end of file -+Element Call
++Element Call
\ No newline at end of file diff --git a/patches/@types+mdx+2.0.13.patch b/patches/@types+mdx+2.0.13.patch new file mode 100644 index 00000000000..d3d02974f73 --- /dev/null +++ b/patches/@types+mdx+2.0.13.patch @@ -0,0 +1,46 @@ +diff --git a/node_modules/@types/mdx/types.d.ts b/node_modules/@types/mdx/types.d.ts +index 498bb69..4e89216 100644 +--- a/node_modules/@types/mdx/types.d.ts ++++ b/node_modules/@types/mdx/types.d.ts +@@ -5,7 +5,7 @@ + */ + // @ts-ignore JSX runtimes may optionally define JSX.ElementType. The MDX types need to work regardless whether this is + // defined or not. +-type ElementType = any extends JSX.ElementType ? never : JSX.ElementType; ++type ElementType = any extends JSX.ElementType ? never : React.JSX.ElementType; + + /** + * This matches any function component types that ar part of `ElementType`. +@@ -20,12 +20,12 @@ type ClassElementType = Extract) => + /** + * A valid JSX string component. + */ +-type StringComponent = Extract; ++type StringComponent = Extract; + + /** + * A JSX element returned by MDX content. + */ +-export type Element = JSX.Element; ++export type Element = React.JSX.Element; + + /** + * A valid JSX function component. +@@ -44,7 +44,7 @@ type FunctionComponent = ElementType extends never + */ + type ClassComponent = ElementType extends never + // If JSX.ElementType isn’t defined, the valid return type is a constructor that returns JSX.ElementClass +- ? new(props: Props) => JSX.ElementClass ++ ? new(props: Props) => React.JSX.ElementClass + : ClassElementType extends never + // If JSX.ElementType is defined, but doesn’t allow constructors, function components are disallowed. + ? never +@@ -70,7 +70,7 @@ interface NestedMDXComponents { + export type MDXComponents = + & NestedMDXComponents + & { +- [Key in StringComponent]?: Component; ++ [Key in StringComponent]?: Component; + } + & { + /** diff --git a/patches/@types+react+19.0.10.patch b/patches/@types+react+19.0.10.patch deleted file mode 100644 index 6f54d0b3829..00000000000 --- a/patches/@types+react+19.0.10.patch +++ /dev/null @@ -1,31 +0,0 @@ -diff --git a/node_modules/@types/react/index.d.ts b/node_modules/@types/react/index.d.ts -index 2272032..18bd20a 100644 ---- a/node_modules/@types/react/index.d.ts -+++ b/node_modules/@types/react/index.d.ts -@@ -134,7 +134,7 @@ declare namespace React { - props: P, - ) => ReactNode | Promise) - // constructor signature must match React.Component -- | (new(props: P) => Component); -+ | (new(props: P, context?: any) => Component); - - /** - * Created by {@link createRef}, or {@link useRef} when passed `null`. -@@ -941,7 +941,7 @@ declare namespace React { - context: unknown; - - // Keep in sync with constructor signature of JSXElementConstructor and ComponentClass. -- constructor(props: P); -+ constructor(props: P, context?: unknown); - - // We MUST keep setState() as a unified signature because it allows proper checking of the method return type. - // See: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18365#issuecomment-351013257 -@@ -1113,7 +1113,7 @@ declare namespace React { - */ - interface ComponentClass

extends StaticLifecycle { - // constructor signature must match React.Component -- new(props: P): Component; -+ new(props: P, context?: any): Component; - /** - * Ignored by React. - * @deprecated Only kept in types for backwards compatibility. Will be removed in a future major release. diff --git a/res/apple-app-site-association b/res/apple-app-site-association index 94869effabb..0235e6ada1e 100644 --- a/res/apple-app-site-association +++ b/res/apple-app-site-association @@ -5,8 +5,7 @@ "appIDs": [ "7J4U792NQT.im.vector.app", "7J4U792NQT.io.element.elementx", - "7J4U792NQT.io.element.elementx.nightly", - "7J4U792NQT.io.element.elementx.pr" + "7J4U792NQT.io.element.elementx.nightly" ], "components": [ { @@ -28,8 +27,7 @@ "apps": [ "7J4U792NQT.im.vector.app", "7J4U792NQT.io.element.elementx", - "7J4U792NQT.io.element.elementx.nightly", - "7J4U792NQT.io.element.elementx.pr" + "7J4U792NQT.io.element.elementx.nightly" ] } } diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 18753d98ea8..83b6f41c52a 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -624,6 +624,7 @@ legend { .mx_Dialog button:not( .mx_EncryptionUserSettingsTab button, + .mx_EncryptionCard button, .mx_UserProfileSettings button, .mx_ShareDialog button, .mx_UnpinAllDialog button, @@ -631,6 +632,7 @@ legend { .mx_Dialog_nonDialogButton, .mx_AccessibleButton, .mx_IdentityServerPicker button, + .mx_AccessSecretStorageDialog button, [class|="maplibregl"] ), .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton), diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 277aa8cd3ab..57e14e0814b 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -53,8 +53,6 @@ @import "./components/views/settings/shared/_SettingsSubsectionHeading.pcss"; @import "./components/views/spaces/_QuickThemeSwitcher.pcss"; @import "./components/views/typography/_Caption.pcss"; -@import "./components/views/utils/_Box.pcss"; -@import "./components/views/utils/_Flex.pcss"; @import "./compound/_Icon.pcss"; @import "./compound/_SuccessDialog.pcss"; @import "./structures/_AutoHideScrollbar.pcss"; @@ -99,7 +97,6 @@ @import "./structures/auth/_Registration.pcss"; @import "./structures/auth/_SessionLockStolenView.pcss"; @import "./structures/auth/_SetupEncryptionBody.pcss"; -@import "./views/audio_messages/_AudioPlayer.pcss"; @import "./views/audio_messages/_PlayPauseButton.pcss"; @import "./views/audio_messages/_PlaybackContainer.pcss"; @import "./views/audio_messages/_SeekBar.pcss"; @@ -133,6 +130,7 @@ @import "./views/dialogs/_BugReportDialog.pcss"; @import "./views/dialogs/_ChangelogDialog.pcss"; @import "./views/dialogs/_CompoundDialog.pcss"; +@import "./views/dialogs/_ConfirmKeyStorageOffDialog.pcss"; @import "./views/dialogs/_ConfirmSpaceUserActionDialog.pcss"; @import "./views/dialogs/_ConfirmUserActionDialog.pcss"; @import "./views/dialogs/_CreateRoomDialog.pcss"; @@ -146,6 +144,7 @@ @import "./views/dialogs/_GenericFeatureFeedbackDialog.pcss"; @import "./views/dialogs/_IncomingSasDialog.pcss"; @import "./views/dialogs/_InviteDialog.pcss"; +@import "./views/dialogs/_InviteProgressBody.pcss"; @import "./views/dialogs/_JoinRuleDropdown.pcss"; @import "./views/dialogs/_LeaveSpaceDialog.pcss"; @import "./views/dialogs/_LocationViewDialog.pcss"; @@ -178,7 +177,6 @@ @import "./views/dialogs/_WidgetCapabilitiesPromptDialog.pcss"; @import "./views/dialogs/security/_AccessSecretStorageDialog.pcss"; @import "./views/dialogs/security/_CreateCrossSigningDialog.pcss"; -@import "./views/dialogs/security/_CreateKeyBackupDialog.pcss"; @import "./views/dialogs/security/_CreateSecretStorageDialog.pcss"; @import "./views/dialogs/security/_KeyBackupFailedDialog.pcss"; @import "./views/dialogs/security/_RestoreKeyBackupDialog.pcss"; @@ -282,6 +280,7 @@ @import "./views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss"; @import "./views/rooms/RoomListPanel/_RoomListSearch.pcss"; @import "./views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss"; +@import "./views/rooms/RoomListPanel/_RoomListSkeleton.pcss"; @import "./views/rooms/_AppsDrawer.pcss"; @import "./views/rooms/_Autocomplete.pcss"; @import "./views/rooms/_AuxPanel.pcss"; diff --git a/res/css/_font-sizes.pcss b/res/css/_font-sizes.pcss index 528cc3c4621..98ebf28af0d 100644 --- a/res/css/_font-sizes.pcss +++ b/res/css/_font-sizes.pcss @@ -12,31 +12,39 @@ Please see LICENSE files in the repository root for full details. * These are defined in `rem` so that they scale with the `font-size` of the root element (which is adjustable via the * "Font size" setting). They exist to make the job of converting designs (which tend to be based in pixels) into CSS * easier. + */ + +/* + * These variables are now *deprecated* and should not be used in new code; instead Compound typographic tokens + * should be used. Direct equivalents for these old font size tokens are listed below; where no equivalent exists, + * that suggests that the design is using a non-standard font size and should be updated. * + * In fact, modern Figma designs should actually use a named Typography style such as "Web/font/heading/sm/semibold", + * translates directly to `font: var(--cpd-font-heading-sm-semibold)`. */ $font-1px: 0.0625rem; $font-8px: 0.5rem; $font-9px: 0.5625rem; $font-10px: 0.625rem; $font-10-4px: 0.6275rem; -$font-11px: 0.6875rem; +$font-11px: 0.6875rem; /* Compound equivalent: --cpd-font-size-body-xs */ $font-12px: 0.75rem; -$font-13px: 0.8125rem; +$font-13px: 0.8125rem; /* Compound equivalent: --cpd-font-size-body-sm */ $font-14px: 0.875rem; -$font-15px: 0.9375rem; +$font-15px: 0.9375rem; /* Compound equivalent: --cpd-font-size-body-md */ $font-16px: 1rem; -$font-17px: 1.0625rem; +$font-17px: 1.0625rem; /* Compound equivalent: --cpd-font-size-body-lg */ $font-18px: 1.125rem; -$font-20px: 1.25rem; +$font-20px: 1.25rem; /* Compound equivalent: --cpd-font-size-heading-sm */ $font-22px: 1.375rem; $font-23px: 1.4375rem; -$font-24px: 1.5rem; +$font-24px: 1.5rem; /* Compound equivalent: --cpd-font-size-heading-md */ $font-25px: 1.5625rem; $font-26px: 1.625rem; -$font-28px: 1.75rem; +$font-28px: 1.75rem; /* Compound equivalent: --cpd-font-size-heading-lg */ $font-29px: 1.8125rem; $font-30px: 1.875rem; -$font-32px: 2rem; +$font-32px: 2rem; /* Compound equivalent: --cpd-font-size-heading-xl */ $font-34px: 2.125rem; $font-35px: 2.1875rem; $font-39px: 2.4375rem; diff --git a/res/css/components/views/dialogs/polls/_PollListItem.pcss b/res/css/components/views/dialogs/polls/_PollListItem.pcss index 6cb46a21d2d..cd24c759376 100644 --- a/res/css/components/views/dialogs/polls/_PollListItem.pcss +++ b/res/css/components/views/dialogs/polls/_PollListItem.pcss @@ -15,7 +15,7 @@ Please see LICENSE files in the repository root for full details. display: grid; justify-content: left; align-items: center; - grid-gap: $spacing-8; + gap: $spacing-8; grid-template-columns: auto auto auto; grid-template-rows: auto; cursor: pointer; diff --git a/res/css/components/views/dialogs/polls/_PollListItemEnded.pcss b/res/css/components/views/dialogs/polls/_PollListItemEnded.pcss index 772b47c9a49..2eb7a185ac4 100644 --- a/res/css/components/views/dialogs/polls/_PollListItemEnded.pcss +++ b/res/css/components/views/dialogs/polls/_PollListItemEnded.pcss @@ -22,7 +22,7 @@ Please see LICENSE files in the repository root for full details. display: grid; justify-content: left; align-items: center; - grid-gap: $spacing-8; + gap: $spacing-8; grid-template-columns: min-content 1fr min-content; grid-template-rows: auto; } @@ -47,7 +47,7 @@ Please see LICENSE files in the repository root for full details. .mx_PollListItemEnded_answers { display: grid; - grid-gap: $spacing-8; + gap: $spacing-8; margin-top: $spacing-12; } diff --git a/res/css/components/views/settings/devices/_DeviceDetailHeading.pcss b/res/css/components/views/settings/devices/_DeviceDetailHeading.pcss index 789efa9e7f8..82b19b9ff13 100644 --- a/res/css/components/views/settings/devices/_DeviceDetailHeading.pcss +++ b/res/css/components/views/settings/devices/_DeviceDetailHeading.pcss @@ -19,7 +19,7 @@ Please see LICENSE files in the repository root for full details. .mx_DeviceDetailHeading_renameForm { display: grid; - grid-gap: $spacing-16; + gap: $spacing-16; justify-content: left; grid-template-columns: 100%; } diff --git a/res/css/components/views/settings/devices/_DeviceDetails.pcss b/res/css/components/views/settings/devices/_DeviceDetails.pcss index d3635710f3a..4b311d1c7c2 100644 --- a/res/css/components/views/settings/devices/_DeviceDetails.pcss +++ b/res/css/components/views/settings/devices/_DeviceDetails.pcss @@ -23,7 +23,7 @@ Please see LICENSE files in the repository root for full details. border-bottom: 1px solid $quinary-content; display: grid; - grid-gap: $spacing-24; + gap: $spacing-24; justify-content: left; grid-template-columns: 100%; diff --git a/res/css/components/views/settings/devices/_DeviceTile.pcss b/res/css/components/views/settings/devices/_DeviceTile.pcss index e4096329d6a..07ee70792d8 100644 --- a/res/css/components/views/settings/devices/_DeviceTile.pcss +++ b/res/css/components/views/settings/devices/_DeviceTile.pcss @@ -35,7 +35,7 @@ Please see LICENSE files in the repository root for full details. .mx_DeviceTile_actions { display: grid; - grid-gap: $spacing-8; + gap: $spacing-8; grid-auto-flow: column; margin-left: $spacing-8; } diff --git a/res/css/components/views/settings/devices/_FilteredDeviceList.pcss b/res/css/components/views/settings/devices/_FilteredDeviceList.pcss index aac5986280e..06f5a80b651 100644 --- a/res/css/components/views/settings/devices/_FilteredDeviceList.pcss +++ b/res/css/components/views/settings/devices/_FilteredDeviceList.pcss @@ -15,7 +15,7 @@ Please see LICENSE files in the repository root for full details. .mx_FilteredDeviceList_list { list-style-type: none; display: grid; - grid-gap: $spacing-16; + gap: $spacing-16; margin: 0; padding: 0 $spacing-16; } diff --git a/res/css/components/views/settings/shared/_SettingsSubsection.pcss b/res/css/components/views/settings/shared/_SettingsSubsection.pcss index 0d03a12b1db..3b22c679c5e 100644 --- a/res/css/components/views/settings/shared/_SettingsSubsection.pcss +++ b/res/css/components/views/settings/shared/_SettingsSubsection.pcss @@ -39,7 +39,7 @@ Please see LICENSE files in the repository root for full details. .mx_SettingsSubsection_content { width: 100%; display: grid; - grid-gap: $spacing-8; + gap: $spacing-8; /* setting minwidth 0 makes columns definitely sized fixing horizontal overflow */ grid-template-columns: minmax(0, 1fr); justify-items: flex-start; diff --git a/res/css/shared.pcss b/res/css/shared.pcss new file mode 100644 index 00000000000..42f83936668 --- /dev/null +++ b/res/css/shared.pcss @@ -0,0 +1,9 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound); +@import url("@vector-im/compound-web/dist/style.css"); diff --git a/res/css/structures/ErrorView.pcss b/res/css/structures/ErrorView.pcss index ddc510e1882..805456bdbbc 100644 --- a/res/css/structures/ErrorView.pcss +++ b/res/css/structures/ErrorView.pcss @@ -50,7 +50,7 @@ Please see LICENSE files in the repository root for full details. color: var(--cpd-color-text-secondary); } - .mx_Flex { + .mx_ErrorView_flexContainer { margin: 0 auto; max-width: max-content; flex-wrap: wrap; diff --git a/res/css/structures/_LeftPanel.pcss b/res/css/structures/_LeftPanel.pcss index 7b8a6e273b1..97e9f20e3f3 100644 --- a/res/css/structures/_LeftPanel.pcss +++ b/res/css/structures/_LeftPanel.pcss @@ -244,3 +244,11 @@ Please see LICENSE files in the repository root for full details. } } } + +.mx_LeftPanel_newRoomList { + /* Thew new rooms list is not designed to be collapsed to just icons. */ + /* 224 + 68(spaces bar) was deemed by design to be a good minimum for the left panel. */ + --collapsedWidth: 224px; + /* Important to force the color on ED titlebar until we remove the old room list */ + background-color: var(--cpd-color-bg-canvas-default) !important; +} diff --git a/res/css/structures/_SpacePanel.pcss b/res/css/structures/_SpacePanel.pcss index 844f48bcc98..1a29d0eaa5f 100644 --- a/res/css/structures/_SpacePanel.pcss +++ b/res/css/structures/_SpacePanel.pcss @@ -30,6 +30,11 @@ Please see LICENSE files in the repository root for full details. width: 68px; } + &.newUi { + background-color: var(--cpd-color-bg-canvas-default); + border-right: 1px solid var(--cpd-color-bg-subtle-primary); + } + .mx_SpacePanel_toggleCollapse { position: absolute; width: 18px; @@ -400,6 +405,11 @@ Please see LICENSE files in the repository root for full details. } } + &.newUi .mx_UserMenu { + margin-top: var(--cpd-space-4x); + border-bottom: none; + } + /* elecord, rpc */ .mx_UserRPC { padding-bottom: 12px; diff --git a/res/css/structures/_ThreadsActivityCentre.pcss b/res/css/structures/_ThreadsActivityCentre.pcss index a1472108ac9..f26139da64c 100644 --- a/res/css/structures/_ThreadsActivityCentre.pcss +++ b/res/css/structures/_ThreadsActivityCentre.pcss @@ -49,12 +49,12 @@ &:hover, &:hover .mx_ThreadsActivityCentreButton_Icon { background-color: $quaternary-content; - color: $primary-content; + fill: $primary-content; } } & .mx_ThreadsActivityCentreButton_Icon { - color: $secondary-content; + fill: $secondary-content; } } diff --git a/res/css/structures/_ToastContainer.pcss b/res/css/structures/_ToastContainer.pcss index 3f36e100b18..37584ffffca 100644 --- a/res/css/structures/_ToastContainer.pcss +++ b/res/css/structures/_ToastContainer.pcss @@ -79,6 +79,11 @@ Please see LICENSE files in the repository root for full details. background-color: $primary-content; } + &.mx_Toast_icon_key_storage::after { + mask-image: url("@vector-im/compound-design-tokens/icons/settings-solid.svg"); + background-color: $primary-content; + } + &.mx_Toast_icon_labs::after { mask-image: url("$(res)/img/element-icons/flask.svg"); background-color: $secondary-content; diff --git a/res/css/views/audio_messages/_AudioPlayer.pcss b/res/css/views/audio_messages/_AudioPlayer.pcss deleted file mode 100644 index 51e97611f5a..00000000000 --- a/res/css/views/audio_messages/_AudioPlayer.pcss +++ /dev/null @@ -1,59 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2021 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_MediaBody.mx_AudioPlayer_container { - padding: 16px 12px 12px 12px; - - .mx_AudioPlayer_primaryContainer { - display: flex; - - .mx_PlayPauseButton { - margin-right: 8px; - } - - .mx_AudioPlayer_mediaInfo { - flex: 1; - overflow: hidden; /* makes the ellipsis on the file name work */ - - & > * { - display: block; - } - - .mx_AudioPlayer_mediaName { - color: $primary-content; - font-size: $font-15px; - line-height: $font-15px; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - padding-bottom: 4px; /* mimics the line-height differences in the Figma */ - } - - .mx_AudioPlayer_byline { - font-size: $font-12px; - line-height: $font-12px; - } - } - } - - .mx_AudioPlayer_seek { - display: flex; - align-items: center; - - .mx_SeekBar { - flex: 1; - } - - .mx_Clock { - min-width: $font-42px; /* for flexbox */ - padding-left: $spacing-4; /* isolate from seek bar */ - text-align: justify; - white-space: nowrap; - } - } -} diff --git a/res/css/views/dialogs/_ConfirmKeyStorageOffDialog.pcss b/res/css/views/dialogs/_ConfirmKeyStorageOffDialog.pcss new file mode 100644 index 00000000000..5ac53c7b706 --- /dev/null +++ b/res/css/views/dialogs/_ConfirmKeyStorageOffDialog.pcss @@ -0,0 +1,16 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +.mx_ConfirmKeyStorageOffDialog { + .mx_Dialog_border { + width: 600px; + } + + .mx_EncryptionCard { + text-align: center; + } +} diff --git a/res/css/views/dialogs/_InviteDialog.pcss b/res/css/views/dialogs/_InviteDialog.pcss index 70a8cdc6087..0f952049cf5 100644 --- a/res/css/views/dialogs/_InviteDialog.pcss +++ b/res/css/views/dialogs/_InviteDialog.pcss @@ -63,17 +63,6 @@ Please see LICENSE files in the repository root for full details. height: 25px; line-height: $font-25px; } - - .mx_InviteDialog_buttonAndSpinner { - .mx_Spinner { - /* Width and height are required to trick the layout engine. */ - width: 20px; - height: 20px; - margin-inline-start: 5px; - display: inline-block; - vertical-align: middle; - } - } } .mx_InviteDialog_section { @@ -218,6 +207,10 @@ Please see LICENSE files in the repository root for full details. flex-direction: column; flex-grow: 1; overflow: hidden; + + .mx_InviteProgressBody { + margin-top: var(--cpd-space-12x); + } } .mx_InviteDialog_transfer { diff --git a/res/css/views/dialogs/_InviteProgressBody.pcss b/res/css/views/dialogs/_InviteProgressBody.pcss new file mode 100644 index 00000000000..e3069a133c0 --- /dev/null +++ b/res/css/views/dialogs/_InviteProgressBody.pcss @@ -0,0 +1,16 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +.mx_InviteProgressBody { + text-align: center; + font: var(--cpd-font-body-lg-regular); + + h1 { + color: var(--cpd-color-text-primary); + font: var(--cpd-font-heading-sm-semibold); + } +} diff --git a/res/css/views/dialogs/_SettingsDialog.pcss b/res/css/views/dialogs/_SettingsDialog.pcss index 186a82c0f5d..2b65bff63ba 100644 --- a/res/css/views/dialogs/_SettingsDialog.pcss +++ b/res/css/views/dialogs/_SettingsDialog.pcss @@ -30,4 +30,28 @@ Please see LICENSE files in the repository root for full details. /* colliding harshly with the dialog when scrolled down. */ padding-bottom: 100px; } + + .mx_SettingsDialog_tabLabelsAlert::after { + display: inline-block; + content: ""; + width: 8px; + height: 8px; + background-color: var(--cpd-color-icon-critical-primary); + clip-path: circle(4px); + position: absolute; + right: var(--cpd-space-4x); + } +} + +/* On narrow viewports, the tab labels are hidden, so we need to shift the indicator so it isn't over the tab icon. */ +@media (max-width: 1024px) { + .mx_UserSettingsDialog, + .mx_RoomSettingsDialog, + .mx_SpaceSettingsDialog, + .mx_SpacePreferencesDialog { + .mx_SettingsDialog_tabLabelsAlert::after { + right: var(--cpd-space-1x); + top: var(--cpd-space-1x); + } + } } diff --git a/res/css/views/dialogs/_SpotlightDialog.pcss b/res/css/views/dialogs/_SpotlightDialog.pcss index d00acd6786d..592431c2f19 100644 --- a/res/css/views/dialogs/_SpotlightDialog.pcss +++ b/res/css/views/dialogs/_SpotlightDialog.pcss @@ -412,7 +412,8 @@ Please see LICENSE files in the repository root for full details. .mx_SpotlightDialog_joinRoomAlias, .mx_SpotlightDialog_explorePublicRooms, .mx_SpotlightDialog_explorePublicSpaces, - .mx_SpotlightDialog_startGroupChat { + .mx_SpotlightDialog_startGroupChat, + .mx_SpotlightDialog_searchMessages { padding-left: $spacing-32; position: relative; @@ -451,22 +452,14 @@ Please see LICENSE files in the repository root for full details. mask-image: url("$(res)/img/element-icons/group-members.svg"); } + .mx_SpotlightDialog_searchMessages::before { + mask-image: url("$(res)/img/element-icons/room/search-inset.svg"); + } + .mx_SpotlightDialog_otherSearches_messageSearchText { font-size: $font-15px; line-height: $font-24px; } - - .mx_SpotlightDialog_otherSearches_messageSearchIcon { - display: inline-block; - width: 24px; - height: 24px; - background-color: $secondary-content; - vertical-align: text-bottom; - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; - mask-image: url("$(res)/img/element-icons/room/search-inset.svg"); - } } .mx_SpotlightDialog_result_details { diff --git a/res/css/views/dialogs/security/_AccessSecretStorageDialog.pcss b/res/css/views/dialogs/security/_AccessSecretStorageDialog.pcss index 16962ad15e5..3de035ab305 100644 --- a/res/css/views/dialogs/security/_AccessSecretStorageDialog.pcss +++ b/res/css/views/dialogs/security/_AccessSecretStorageDialog.pcss @@ -7,32 +7,11 @@ Please see LICENSE files in the repository root for full details. */ .mx_AccessSecretStorageDialog { - .mx_AccessSecretStorageDialog_titleWithIcon { - &::before { - content: ""; - display: inline-block; - width: 24px; - height: 24px; - margin-inline-end: $spacing-8; - position: relative; - top: 5px; - background-color: $primary-content; - } - - &.mx_AccessSecretStorageDialog_resetBadge::before { - /* The image isn't capable of masking, so we use a background instead. */ - background-image: url("@vector-im/compound-design-tokens/icons/error-solid.svg"); - background-size: 24px; - background-color: transparent; - } - - &.mx_AccessSecretStorageDialog_secureBackupTitle::before { - mask-image: url("$(res)/img/feather-customised/secure-backup.svg"); - } - - &.mx_AccessSecretStorageDialog_securePhraseTitle::before { - mask-image: url("$(res)/img/feather-customised/secure-phrase.svg"); - } + &.mx_EncryptionCard { + /* override some styles that we don't need */ + border: 0px none; + box-shadow: none; + padding: 0px; } .mx_AccessSecretStorageDialog_primaryContainer { @@ -47,19 +26,19 @@ Please see LICENSE files in the repository root for full details. } .mx_AccessSecretStorageDialog_recoveryKeyEntry { - display: flex; - align-items: center; - - .mx_AccessSecretStorageDialog_recoveryKeyEntry_textInput { - flex-grow: 1; - } - - .mx_AccessSecretStorageDialog_recoveryKeyEntry_entryControlSeparatorText { - margin: $spacing-16; - } - - .mx_AccessSecretStorageDialog_recoveryKeyEntry_fileInput { - display: none; + /* + * Be specific here to avoid "margin: 9px" from _common.pcss + */ + :not(.mx_textinput):not(.mx_Field):not(.mx_no_textinput) { + input { + /* + * From figma: https://www.figma.com/design/ZodBLtGnKmRTGJo5SGLnH3/ER-137--Excluding-Insecure-Devices?node-id=102-43729&t=QmewENUd7f6Tmw9U-1 + */ + width: 448px; + height: 70px; + margin: 0px; + border: 1px solid; + } } } @@ -89,54 +68,14 @@ Please see LICENSE files in the repository root for full details. color: $alert; &::before { - mask-image: url("@vector-im/compound-design-tokens/icons/close.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/error-solid.svg"); background-color: $alert; } } } + } - .mx_Dialog_buttons { - $spacingStart: $spacing-24; /* 16px icon + 8px padding */ - - text-align: initial; - display: flex; - flex-flow: column; - gap: 14px; - - .mx_Dialog_buttons_additive { - float: none; - - .mx_AccessSecretStorageDialog_reset { - position: relative; - padding-inline-start: $spacingStart; - /* To avoid bold styling inherent with elements */ - font-weight: inherit; - - &::before { - content: ""; - display: inline-block; - position: absolute; - height: 16px; - width: 16px; - left: 0; - top: 2px; /* alignment */ - background-image: url("@vector-im/compound-design-tokens/icons/error-solid.svg"); - background-size: contain; - - /* elecord, temp colour fix */ - filter: invert(1) brightness(0.7); - } - - .mx_AccessSecretStorageDialog_reset_link { - color: $alert; - } - } - } - - .mx_Dialog_buttons_row { - gap: $spacing-16; /* TODO: needs normalization */ - padding-inline-start: $spacingStart; - } - } + .mx_EncryptionCard_buttons { + margin-top: var(--cpd-space-20x); } } diff --git a/res/css/views/dialogs/security/_CreateKeyBackupDialog.pcss b/res/css/views/dialogs/security/_CreateKeyBackupDialog.pcss deleted file mode 100644 index 9bd85398818..00000000000 --- a/res/css/views/dialogs/security/_CreateKeyBackupDialog.pcss +++ /dev/null @@ -1,73 +0,0 @@ -/* -Copyright 2018-2024 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_CreateKeyBackupDialog .mx_Dialog_title { - /* TODO: Consider setting this for all dialog titles. */ - margin-bottom: 1em; -} - -.mx_CreateKeyBackupDialog_primaryContainer { - /* FIXME: plinth colour in new theme(s). background-color: $accent; */ - padding: 20px; -} - -.mx_CreateKeyBackupDialog_primaryContainer::after { - content: ""; - clear: both; - display: block; -} - -.mx_CreateKeyBackupDialog_passPhraseContainer { - display: flex; - align-items: flex-start; -} - -.mx_CreateKeyBackupDialog_passPhraseInput { - flex: none; - width: 250px; - border: 1px solid $accent; - border-radius: 5px; - padding: 10px; - margin-bottom: 1em; -} - -.mx_CreateKeyBackupDialog_passPhraseMatch { - margin-left: 20px; -} - -.mx_CreateKeyBackupDialog_recoveryKeyHeader { - margin-bottom: 1em; -} - -.mx_CreateKeyBackupDialog_recoveryKeyContainer { - display: flex; -} - -.mx_CreateKeyBackupDialog_recoveryKey { - width: 262px; - padding: 20px; - color: $info-plinth-fg-color; - background-color: $info-plinth-bg-color; - margin-right: 12px; -} - -.mx_CreateKeyBackupDialog_recoveryKeyButtons { - flex: 1; - display: flex; - align-items: center; -} - -.mx_CreateKeyBackupDialog_recoveryKeyButtons button { - flex: 1; - white-space: nowrap; -} - -.mx_CreateKeyBackupDialog { - details .mx_AccessibleButton { - margin: 1em 0; /* emulate paragraph spacing because we can't put this button in a paragraph due to HTML rules */ - } -} diff --git a/res/css/views/elements/_Pill.pcss b/res/css/views/elements/_Pill.pcss index d692f812a4e..4b3fd3bb685 100644 --- a/res/css/views/elements/_Pill.pcss +++ b/res/css/views/elements/_Pill.pcss @@ -11,8 +11,7 @@ Please see LICENSE files in the repository root for full details. line-height: $font-17px; border-radius: $font-16px; vertical-align: text-top; - display: inline-flex; - align-items: center; + display: inline-block; box-sizing: border-box; max-width: 100%; overflow: hidden; @@ -57,6 +56,8 @@ Please see LICENSE files in the repository root for full details. margin-inline-start: -0.3em; /* Otherwise the gap is too large */ margin-inline-end: 0.2em; min-width: $font-16px; /* ensure the avatar is not compressed */ + user-select: text; + vertical-align: -2.5px; } .mx_Pill_text { diff --git a/res/css/views/messages/_MPollBody.pcss b/res/css/views/messages/_MPollBody.pcss index 33144083ea9..9889bb81bb2 100644 --- a/res/css/views/messages/_MPollBody.pcss +++ b/res/css/views/messages/_MPollBody.pcss @@ -60,7 +60,7 @@ Please see LICENSE files in the repository root for full details. .mx_MPollBody_allOptions { display: grid; - grid-gap: $spacing-16; + gap: $spacing-16; margin-bottom: $spacing-8; max-width: 550px; } diff --git a/res/css/views/polls/pollHistory/_PollHistoryList.pcss b/res/css/views/polls/pollHistory/_PollHistoryList.pcss index 95d54192f91..b7325451786 100644 --- a/res/css/views/polls/pollHistory/_PollHistoryList.pcss +++ b/res/css/views/polls/pollHistory/_PollHistoryList.pcss @@ -21,12 +21,12 @@ Please see LICENSE files in the repository root for full details. flex: 1 1 0; align-content: flex-start; display: grid; - grid-gap: $spacing-20; + gap: $spacing-20; padding-right: $spacing-64; margin: $spacing-32 0; &.mx_PollHistoryList_list_ENDED { - grid-gap: $spacing-32; + gap: $spacing-32; } } diff --git a/res/css/views/right_panel/_ExtensionsCard.pcss b/res/css/views/right_panel/_ExtensionsCard.pcss index 0dbfc056cd2..c98fa3e9dcf 100644 --- a/res/css/views/right_panel/_ExtensionsCard.pcss +++ b/res/css/views/right_panel/_ExtensionsCard.pcss @@ -7,12 +7,11 @@ Please see LICENSE files in the repository root for full details. */ .mx_ExtensionsCard { - --cpd-separator-inset: var(--cpd-space-4x); - --cpd-separator-spacing: var(--cpd-space-4x); - + --cpd-separator-spacing: var(--cpd-space-6x); + --AddExtension-overlap: -76px; .mx_AutoHideScrollbar { padding: 0 var(--cpd-space-4x); - margin-top: var(--cpd-space-3x); + margin-top: var(--cpd-space-6x); box-sizing: border-box; /* Styling for the "Add extensions" button */ @@ -128,6 +127,11 @@ Please see LICENSE files in the repository root for full details. .mx_EmptyState::before { /* Overlap the Add extensions button */ - top: -76px; + top: var(--AddExtension-overlap); + } + + .mx_EmptyState { + /* Stop empty state scrolling */ + height: calc(100% + var(--AddExtension-overlap)); } } diff --git a/res/css/views/right_panel/_RoomSummaryCard.pcss b/res/css/views/right_panel/_RoomSummaryCard.pcss index 8ddf7aec108..7e6ef2b4dc2 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.pcss +++ b/res/css/views/right_panel/_RoomSummaryCard.pcss @@ -32,7 +32,7 @@ Please see LICENSE files in the repository root for full details. padding: 0 12px; color: var(--cpd-color-text-secondary); - .mx_Box { + .mx_RoomSummaryCard_topic_box { width: 100%; } diff --git a/res/css/views/right_panel/_UserInfo.pcss b/res/css/views/right_panel/_UserInfo.pcss index 3030b93c030..2b6480bd841 100644 --- a/res/css/views/right_panel/_UserInfo.pcss +++ b/res/css/views/right_panel/_UserInfo.pcss @@ -108,11 +108,6 @@ Please see LICENSE files in the repository root for full details. margin: 0; font-size: $font-20px; line-height: $font-25px; - - /* E2E icon wrapper */ - .mx_Flex > span { - display: inline-block; - } } .mx_UserInfo_profile_name { diff --git a/res/css/views/rooms/RoomListPanel/_RoomListItemView.pcss b/res/css/views/rooms/RoomListPanel/_RoomListItemView.pcss index 1e61bf7f3c5..06ffe532d7c 100644 --- a/res/css/views/rooms/RoomListPanel/_RoomListItemView.pcss +++ b/res/css/views/rooms/RoomListPanel/_RoomListItemView.pcss @@ -7,79 +7,74 @@ /** * The RoomListItemView has the following structure: - * button----------------------------------------| - * | <-12px-> container--------------------------| - * | | room avatar <-12px-> content-----| - * | | | room_name | - * | | | ----------| <-- border - * |---------------------------------------------| + * button--------------------------------------------------| + * | <-12px-> container------------------------------------| + * | | room avatar <-8px-> content----------------| + * | | | room_name <- 20px ->| + * | | | --------------------| <-- border + * |-------------------------------------------------------| */ .mx_RoomListItemView { - all: unset; + /* Remove button default style */ + background: unset; + border: none; + padding: 0; + text-align: unset; + cursor: pointer; + height: 48px; + width: 100%; - &:hover { - background-color: var(--cpd-color-bg-action-secondary-hovered); - } + padding-left: var(--cpd-space-3x); + font: var(--cpd-font-body-md-regular); - .mx_RoomListItemView_container { - padding-left: var(--cpd-space-2x); - font: var(--cpd-font-body-md-regular); + .mx_RoomListItemView_content { height: 100%; + flex: 1; + /* The border is only under the room name and the future hover menu */ + border-bottom: var(--cpd-border-width-0-5) solid var(--cpd-color-bg-subtle-secondary); + box-sizing: border-box; + min-width: 0; + padding-right: var(--cpd-space-5x); - .mx_RoomListItemView_content { - height: 100%; - flex: 1; - /* The border is only under the room name and the future hover menu */ - border-bottom: var(--cpd-border-width-0-5) solid var(--cpd-color-bg-subtle-secondary); - box-sizing: border-box; + .mx_RoomListItemView_text { min-width: 0; + } - .mx_RoomListItemView_text { - min-width: 0; - } - - .mx_RoomListItemView_roomName { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } + .mx_RoomListItemView_roomName { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } - .mx_RoomListItemView_messagePreview { - font: var(--cpd-font-body-sm-regular); - color: var(--cpd-color-text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } + .mx_RoomListItemView_messagePreview { + font: var(--cpd-font-body-sm-regular); + color: var(--cpd-color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } } } -.mx_RoomListItemView_menu_open { +.mx_RoomListItemView_hover { background-color: var(--cpd-color-bg-action-secondary-hovered); +} - .mx_RoomListItemView_content { - padding-right: var(--cpd-space-1-5x); - } +.mx_RoomListItemView_menu_open .mx_RoomListItemView_content { + /** + * The figma uses 16px padding (--cpd-space-4x) but due to https://github.com/element-hq/compound-web/issues/331 + * the icon size of the menu is 18px instead of 20px with a different internal padding + * We need to use 18px to align the icon with the others icons + * 18px is not available in compound spacing + */ + padding-right: 18px; } .mx_RoomListItemView_selected { background-color: var(--cpd-color-bg-action-secondary-pressed); } -.mx_RoomListItemView_notification_decoration { - .mx_RoomListItemView_content { - padding-right: var(--cpd-space-2x); - } -} - -.mx_RoomListItemView_empty { - .mx_RoomListItemView_content { - padding-right: var(--cpd-space-3x); - } -} - .mx_RoomListItemView_bold .mx_RoomListItemView_roomName { font: var(--cpd-font-body-md-semibold); } diff --git a/res/css/views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss b/res/css/views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss index ac85782bbd0..f8fc31ae124 100644 --- a/res/css/views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss +++ b/res/css/views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss @@ -6,7 +6,32 @@ */ .mx_RoomListPrimaryFilters { - margin: unset; - list-style-type: none; - padding: var(--cpd-space-2x) var(--cpd-space-3x); + padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-3x); + + .mx_RoomListPrimaryFilters_wrapping { + display: none; + } + + ul { + margin: unset; + padding: unset; + list-style-type: none; + /** + * The InteractionObserver needs the height to be set to work properly. + */ + height: 100%; + flex: 1; + } + + .mx_RoomListPrimaryFilters_IconButton { + svg { + transition: transform 0.1s linear; + } + } + + .mx_RoomListPrimaryFilters_IconButton[aria-expanded="true"] { + svg { + transform: rotate(180deg); + } + } } diff --git a/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss b/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss index 8a97086df8e..472badc3ad8 100644 --- a/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss +++ b/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss @@ -12,15 +12,16 @@ border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-bg-subtle-primary); padding: 0 var(--cpd-space-3x); - svg { - fill: var(--cpd-color-icon-secondary); - } - .mx_RoomListSearch_search { /* The search button should take all the remaining space */ flex: 1; font: var(--cpd-font-body-md-regular); color: var(--cpd-color-text-secondary); + min-width: 0; + + svg { + fill: var(--cpd-color-icon-secondary); + } span { flex: 1; @@ -28,12 +29,17 @@ kbd { font-family: inherit; } - } - } - .mx_RoomListSearch_button:hover { - svg { - fill: var(--cpd-color-icon-primary); + /* Shrink and truncate the search text */ + white-space: nowrap; + overflow: hidden; + .mx_RoomListSearch_search_text { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: start; + } } } } diff --git a/res/css/views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss b/res/css/views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss index c94fb54007d..0fa8dc12aef 100644 --- a/res/css/views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss +++ b/res/css/views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss @@ -10,16 +10,3 @@ margin: var(--cpd-space-2x); margin-left: var(--cpd-space-1x); } - -.mx_RoomListSecondaryFilters_roomOptionsButton { - /* Size the button appropriately (should this be in em, maybe, - * so it gets bigger with font size? These values taken from the figma. - */ - width: 28px; - height: 28px; - margin-left: auto; - - svg { - color: var(--cpd-color-icon-primary); - } -} diff --git a/res/css/views/rooms/RoomListPanel/_RoomListSkeleton.pcss b/res/css/views/rooms/RoomListPanel/_RoomListSkeleton.pcss new file mode 100644 index 00000000000..2e644cbba1e --- /dev/null +++ b/res/css/views/rooms/RoomListPanel/_RoomListSkeleton.pcss @@ -0,0 +1,24 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.mx_RoomListSkeleton { + position: relative; + margin-left: 4px; + height: 100%; + + &::before { + background-color: var(--cpd-color-bg-subtle-secondary); + width: 100%; + height: 100%; + + content: ""; + position: absolute; + mask-repeat: repeat-y; + mask-size: auto 96px; + mask-image: url("$(res)/img/element-icons/roomlist/room-list-item-skeleton.svg"); + } +} diff --git a/res/css/views/rooms/_E2EIcon.pcss b/res/css/views/rooms/_E2EIcon.pcss index f3aaf8a8833..7dd0aa476dd 100644 --- a/res/css/views/rooms/_E2EIcon.pcss +++ b/res/css/views/rooms/_E2EIcon.pcss @@ -55,6 +55,13 @@ Please see LICENSE files in the repository root for full details. background-color: var(--cpd-color-icon-tertiary); } +.mx_E2EIcon_verified, +.mx_E2EIcon_warning { + .mx_E2EIcon_normal::after { + background-color: white; + } +} + .mx_E2EIcon_verified::after { mask-image: url("$(res)/img/e2e/verified.svg"); background-color: $e2e-verified-color; diff --git a/res/css/views/rooms/_RoomHeader.pcss b/res/css/views/rooms/_RoomHeader.pcss index 320a482c74f..99f1f366ff7 100644 --- a/res/css/views/rooms/_RoomHeader.pcss +++ b/res/css/views/rooms/_RoomHeader.pcss @@ -106,5 +106,5 @@ Please see LICENSE files in the repository root for full details. } .mx_RoomHeader .mx_RoomHeader_toggled { - color: var(--cpd-color-icon-accent-primary); + fill: var(--cpd-color-icon-accent-primary); } diff --git a/res/css/views/rooms/_RoomSublist.pcss b/res/css/views/rooms/_RoomSublist.pcss index 7b0da5608ec..3361bce4bbf 100644 --- a/res/css/views/rooms/_RoomSublist.pcss +++ b/res/css/views/rooms/_RoomSublist.pcss @@ -404,8 +404,7 @@ Please see LICENSE files in the repository root for full details. height: 240px; &::before { - background: $roomsublist-skeleton-ui-bg; - + background-color: var(--cpd-color-bg-subtle-secondary); width: 100%; height: 100%; diff --git a/res/css/views/settings/_JoinRuleSettings.pcss b/res/css/views/settings/_JoinRuleSettings.pcss index 485434f0da5..fcb21fca968 100644 --- a/res/css/views/settings/_JoinRuleSettings.pcss +++ b/res/css/views/settings/_JoinRuleSettings.pcss @@ -53,6 +53,14 @@ Please see LICENSE files in the repository root for full details. display: block; } + &.mx_StyledRadioButton_disabled { + opacity: 0.5; + } + + &.mx_StyledRadioButton_disabled + span { + opacity: 0.5; + } + & + span { display: inline-block; margin-left: 34px; @@ -71,3 +79,7 @@ Please see LICENSE files in the repository root for full details. font: var(--cpd-font-body-md-regular); margin-top: var(--cpd-space-2x); } + +.mx_JoinRuleSettings_recommended { + color: $accent-1000; +} diff --git a/res/css/views/settings/_NotificationSettings2.pcss b/res/css/views/settings/_NotificationSettings2.pcss index d579c22b95b..285282c89c5 100644 --- a/res/css/views/settings/_NotificationSettings2.pcss +++ b/res/css/views/settings/_NotificationSettings2.pcss @@ -30,7 +30,7 @@ Please see LICENSE files in the repository root for full details. .mx_SettingsSubsection_content { margin-top: 12px; - grid-gap: 12px; + gap: 12px; justify-items: stretch; justify-content: stretch; } @@ -40,7 +40,7 @@ Please see LICENSE files in the repository root for full details. } .mx_NotificationSettings2_flags { - grid-gap: 4px; + gap: 4px; } .mx_StyledRadioButton_content { diff --git a/res/css/views/settings/_Notifications.pcss b/res/css/views/settings/_Notifications.pcss index e4e450fd581..a97d7529d5c 100644 --- a/res/css/views/settings/_Notifications.pcss +++ b/res/css/views/settings/_Notifications.pcss @@ -11,7 +11,7 @@ Please see LICENSE files in the repository root for full details. display: grid; grid-template-columns: auto repeat(3, 62px); place-items: center center; - grid-gap: 8px; + gap: 8px; /* Override StyledRadioButton default styles */ .mx_StyledRadioButton { diff --git a/res/css/views/settings/_SettingsHeader.pcss b/res/css/views/settings/_SettingsHeader.pcss index a705deda6cf..e1c470214f9 100644 --- a/res/css/views/settings/_SettingsHeader.pcss +++ b/res/css/views/settings/_SettingsHeader.pcss @@ -16,4 +16,13 @@ font: var(--cpd-font-body-sm-medium); color: var(--cpd-color-text-action-accent); } + + &.mx_SettingsHeader_recommended::after { + display: inline-block; + content: ""; + width: 8px; + height: 8px; + background-color: var(--cpd-color-icon-critical-primary); + clip-path: circle(4px); + } } diff --git a/res/css/views/settings/encryption/_ChangeRecoveryKey.pcss b/res/css/views/settings/encryption/_ChangeRecoveryKey.pcss index ceacb22c270..872decadc86 100644 --- a/res/css/views/settings/encryption/_ChangeRecoveryKey.pcss +++ b/res/css/views/settings/encryption/_ChangeRecoveryKey.pcss @@ -68,5 +68,28 @@ display: flex; flex-direction: column; gap: var(--cpd-space-8x); + + .mx_KeyForm_password { + > input[name="recoveryKey"] { + /* + * From figma https://www.figma.com/design/qTWRfItpO3RdCjnTKPu4mL/Settings?node-id=375-77506&t=d82NdRBDoKsUe1C9-4 + */ + height: 70px; + padding: var(--cpd-space-3x) var(--cpd-space-3x) var(--cpd-space-3x) var(--cpd-space-4x); + border: var(--cpd-border-width-1) solid; + border-radius: 8px; + margin: 0px; + } + + > button { + /* + * See figma https://www.figma.com/design/qTWRfItpO3RdCjnTKPu4mL/Settings?node-id=375-77506&t=d82NdRBDoKsUe1C9-4 + * Avoid stretching the hide/show symbol to the height of the input, and centre it vertically. + */ + height: 24.5px; + padding: var(--cpd-space-1x); + align-self: center; + } + } } } diff --git a/res/css/views/settings/tabs/_SettingsSection.pcss b/res/css/views/settings/tabs/_SettingsSection.pcss index 997343190dc..ce3c9266c39 100644 --- a/res/css/views/settings/tabs/_SettingsSection.pcss +++ b/res/css/views/settings/tabs/_SettingsSection.pcss @@ -34,7 +34,7 @@ Please see LICENSE files in the repository root for full details. .mx_SettingsSection_subSections { display: grid; grid-template-columns: minmax(0, 1fr); - grid-gap: $spacing-32; + gap: $spacing-32; padding: $spacing-16 0; } diff --git a/res/css/views/settings/tabs/_SettingsTab.pcss b/res/css/views/settings/tabs/_SettingsTab.pcss index e0abf08e83b..99690d56577 100644 --- a/res/css/views/settings/tabs/_SettingsTab.pcss +++ b/res/css/views/settings/tabs/_SettingsTab.pcss @@ -84,7 +84,7 @@ Please see LICENSE files in the repository root for full details. .mx_SettingsTab_sections { display: grid; grid-template-columns: 1fr; - grid-gap: $spacing-32; + gap: $spacing-32; padding-bottom: $spacing-16; } diff --git a/res/css/views/settings/tabs/user/_KeyboardUserSettingsTab.pcss b/res/css/views/settings/tabs/user/_KeyboardUserSettingsTab.pcss index b3251f3e3c1..44452be7446 100644 --- a/res/css/views/settings/tabs/user/_KeyboardUserSettingsTab.pcss +++ b/res/css/views/settings/tabs/user/_KeyboardUserSettingsTab.pcss @@ -12,7 +12,7 @@ Please see LICENSE files in the repository root for full details. padding: 0; width: 100%; display: grid; - grid-gap: $spacing-4; + gap: $spacing-4; } .mx_KeyboardShortcut_shortcutRow, diff --git a/res/css/views/verification/_VerificationShowSas.pcss b/res/css/views/verification/_VerificationShowSas.pcss index 1a24519cbf8..9e4d1f138b3 100644 --- a/res/css/views/verification/_VerificationShowSas.pcss +++ b/res/css/views/verification/_VerificationShowSas.pcss @@ -47,7 +47,7 @@ Please see LICENSE files in the repository root for full details. .mx_VerificationShowSas_emojiSas_label { font-size: $font-12px; - word-break: break-all; + word-break: break-word; } .mx_VerificationShowSas_emojiSas_break { diff --git a/res/img/element-icons/roomlist/room-list-item-skeleton.svg b/res/img/element-icons/roomlist/room-list-item-skeleton.svg new file mode 100644 index 00000000000..adf56e4ed8a --- /dev/null +++ b/res/img/element-icons/roomlist/room-list-item-skeleton.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/res/themes/light-high-contrast/css/_light-high-contrast.pcss b/res/themes/light-high-contrast/css/_light-high-contrast.pcss index 213c6414401..94774bc5b8b 100644 --- a/res/themes/light-high-contrast/css/_light-high-contrast.pcss +++ b/res/themes/light-high-contrast/css/_light-high-contrast.pcss @@ -163,7 +163,8 @@ $accent-1400: var(--cpd-color-green-1400); &.mx_SpotlightDialog_startChat::before, &.mx_SpotlightDialog_joinRoomAlias::before, &.mx_SpotlightDialog_explorePublicRooms::before, - &.mx_SpotlightDialog_startGroupChat::before { + &.mx_SpotlightDialog_startGroupChat::before, + &.mx_SpotlightDialog_searchMessages::before { background-color: $background !important; } diff --git a/res/welcome.html b/res/welcome.html index ef2d43bd8ff..9fdd60a7c02 100644 --- a/res/welcome.html +++ b/res/welcome.html @@ -3,7 +3,7 @@ * voodoo where we have to set display: none by default */ - h1::after { + .mx_Header_title::after { content: "!"; } diff --git a/scripts/gitrm.sh b/scripts/gitrm.sh old mode 100644 new mode 100755 index 926ea38f635..3f8bc96f126 --- a/scripts/gitrm.sh +++ b/scripts/gitrm.sh @@ -122,3 +122,6 @@ rm .github/workflows/update-topics.yaml rm -rf __mocks__/ rm knip.ts rm developer_guide.md + +git rm --cached -r .storybook/ +rm -rf .storybook/ diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 344059fee4c..289bda3f494 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -68,9 +68,17 @@ type ElectronChannel = | "openDesktopCapturerSourcePicker" | "userAccessToken" | "homeserverUrl" - | "serverSupportedVersions"; + | "serverSupportedVersions" + | "showToast"; declare global { + // use `number` as the return type in all cases for globalThis.set{Interval,Timeout}, + // so we don't accidentally use the methods on NodeJS.Timeout - they only exist in a subset of environments. + // The overload for clear{Interval,Timeout} is resolved as expected. + // We use `ReturnType` in the code to be agnostic of if this definition gets loaded. + function setInterval(handler: TimerHandler, timeout: number, ...arguments: any[]): number; + function setTimeout(handler: TimerHandler, timeout: number, ...arguments: any[]): number; + interface Window { mxSendRageshake: (text: string, withLogs?: boolean) => void; matrixLogger: typeof logger; @@ -82,19 +90,10 @@ declare global { mxMatrixClientPeg: IMatrixClientPeg; mxReactSdkConfig: DeepReadonly; - // Needed for Safari, unknown to TypeScript - webkitAudioContext: typeof AudioContext; - // https://docs.microsoft.com/en-us/previous-versions/hh772328(v=vs.85) // we only ever check for its existence, so we can ignore its actual type MSStream?: unknown; - // https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/1029#issuecomment-869224737 - // https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas - OffscreenCanvas?: { - new (width: number, height: number): OffscreenCanvas; - }; - mxContentMessages: ContentMessages; mxToastStore: ToastStore; mxDeviceListener: DeviceListener; @@ -137,8 +136,20 @@ declare global { } interface Electron { + // Legacy on(channel: ElectronChannel, listener: (event: Event, ...args: any[]) => void): void; send(channel: ElectronChannel, ...args: any[]): void; + // Initialisation + initialise(): Promise<{ + protocol: string; + sessionId: string; + supportsBadgeOverlay: boolean; + config: IConfigOptions; + supportedSettings: Record; + }>; + // Settings + setSettingValue(settingName: string, value: any): Promise; + getSettingValue(settingName: string): Promise; } interface DesktopCapturerSource { @@ -156,31 +167,10 @@ declare global { fetchWindowIcons?: boolean; } - interface Document { - // Safari & IE11 only have this prefixed: we used prefixed versions - // previously so let's continue to support them for now - webkitExitFullscreen(): Promise; - msExitFullscreen(): Promise; - readonly webkitFullscreenElement: Element | null; - readonly msFullscreenElement: Element | null; - } - - interface Navigator { - userLanguage?: string; - } - interface StorageEstimate { usageDetails?: { [key: string]: number }; } - interface Element { - // Safari & IE11 only have this prefixed: we used prefixed versions - // previously so let's continue to support them for now - webkitRequestFullScreen(options?: FullscreenOptions): Promise; - msRequestFullscreen(options?: FullscreenOptions): Promise; - // scrollIntoView(arg?: boolean | _ScrollIntoViewOptions): void; - } - // https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278 interface AudioWorkletProcessor { readonly port: MessagePort; @@ -239,11 +229,4 @@ declare global { var mx_rage_store: IndexedDBLogStore; } -// add method which is missing from the node typing -declare module "url" { - interface Url { - format(): string; - } -} - /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/src/@types/invite-rules.ts b/src/@types/invite-rules.ts new file mode 100644 index 00000000000..bc72a5e9222 --- /dev/null +++ b/src/@types/invite-rules.ts @@ -0,0 +1,29 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +export const INVITE_RULES_ACCOUNT_DATA_TYPE = "org.matrix.msc4155.invite_permission_config"; + +export interface InviteConfigAccountData { + allowed_users?: string[]; + blocked_users?: string[]; + ignored_users?: string[]; + allowed_servers?: string[]; + blocked_servers?: string[]; + ignored_servers?: string[]; +} + +/** + * Computed values based on MSC4155. Currently Element Web only supports + * blocking all invites. + */ +export interface ComputedInviteConfig extends Record { + /** + * Are all invites blocked. This is only about blocking all invites, + * but this being false may still block invites through other rules. + */ + allBlocked: boolean; +} diff --git a/src/@types/matrix-js-sdk.d.ts b/src/@types/matrix-js-sdk.d.ts index c81c5377bfc..ad75ca95f05 100644 --- a/src/@types/matrix-js-sdk.d.ts +++ b/src/@types/matrix-js-sdk.d.ts @@ -15,6 +15,7 @@ import type { EmptyObject } from "matrix-js-sdk/src/matrix"; import type { DeviceClientInformation } from "../utils/device/types.ts"; import type { UserWidget } from "../utils/WidgetUtils-types.ts"; import { type MediaPreviewConfig } from "./media_preview.ts"; +import { type INVITE_RULES_ACCOUNT_DATA_TYPE, type InviteConfigAccountData } from "./invite-rules.ts"; // Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types declare module "matrix-js-sdk/src/types" { @@ -60,7 +61,6 @@ declare module "matrix-js-sdk/src/types" { }; }; } - export interface AccountDataEvents { // Analytics account data event "im.vector.analytics": { @@ -89,7 +89,12 @@ declare module "matrix-js-sdk/src/types" { accepted: string[]; }; + // MSC4155: Invite filtering + [INVITE_RULES_ACCOUNT_DATA_TYPE]: InviteConfigAccountData; "io.element.msc4278.media_preview_config": MediaPreviewConfig; + + // Indicate whether recovery is enabled or disabled + "io.element.recovery": { enabled: boolean }; } export interface AudioContent { diff --git a/src/AddThreepid.ts b/src/AddThreepid.ts index 7d2c4cefb5f..877a0c5c4a1 100644 --- a/src/AddThreepid.ts +++ b/src/AddThreepid.ts @@ -79,6 +79,8 @@ export default class AddThreepid { } catch (err) { if (err instanceof MatrixError && err.errcode === "M_THREEPID_IN_USE") { throw new UserFriendlyError("settings|general|email_address_in_use", { cause: err }); + } else if (err instanceof MatrixError && err.errcode === "M_THREEPID_MEDIUM_NOT_SUPPORTED") { + throw new UserFriendlyError("settings|general|email_adding_unsupported_by_hs", { cause: err }); } // Otherwise, just blurt out the same error throw err; @@ -121,6 +123,8 @@ export default class AddThreepid { * @param {string} phoneCountry The ISO 2 letter code of the country to resolve phoneNumber in * @param {string} phoneNumber The national or international formatted phone number to add * @return {Promise} Resolves when the text message has been sent. Then call haveMsisdnToken(). + * + * @throws {UserFriendlyError} An appropriate user-friendly error if the verification code could not be sent. */ public async addMsisdn(phoneCountry: string, phoneNumber: string): Promise { try { @@ -136,6 +140,10 @@ export default class AddThreepid { } catch (err) { if (err instanceof MatrixError && err.errcode === "M_THREEPID_IN_USE") { throw new UserFriendlyError("settings|general|msisdn_in_use", { cause: err }); + } else if (err instanceof MatrixError && err.errcode === "M_THREEPID_MEDIUM_NOT_SUPPORTED") { + throw new UserFriendlyError("settings|general|msisdn_adding_unsupported_by_hs", { cause: err }); + } else if (err instanceof MatrixError && err.errcode === "M_INVALID_PARAM") { + throw new UserFriendlyError("settings|general|invalid_phone_number", { cause: err }); } // Otherwise, just blurt out the same error throw err; diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index c7b7825fe68..05dd437f942 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -72,7 +72,7 @@ export default abstract class BasePlatform { protected _favicon?: Favicon; protected constructor() { - dis.register(this.onAction); + dis.register(this.onAction.bind(this)); this.startUpdateCheck = this.startUpdateCheck.bind(this); } @@ -85,14 +85,14 @@ export default abstract class BasePlatform { */ public abstract getDefaultDeviceDisplayName(): string; - protected onAction = (payload: ActionPayload): void => { + protected onAction(payload: ActionPayload): void { switch (payload.action) { case "on_client_not_viable": case Action.OnLoggedOut: this.setNotificationCount(0); break; } - }; + } // Used primarily for Analytics public abstract getHumanReadableName(): string; @@ -477,6 +477,8 @@ export default abstract class BasePlatform { // The redirect URL has to exactly match that registered at the OIDC server, so // ensure that the fragment part of the URL is empty. url.hash = ""; + // Set no_universal_links=true to prevent the callback being handled by Element X installed on macOS Apple Silicon + url.searchParams.set("no_universal_links", "true"); return url; } @@ -494,15 +496,12 @@ export default abstract class BasePlatform { } private updateFavicon(): void { - let bgColor = "#d00"; - let notif: string | number = this.notificationCount; + const notif: string | number = this.notificationCount; if (this.errorDidOccur) { - notif = notif || "×"; - bgColor = "#f00"; + this.favicon.badge(notif || "×", { bgColor: "#f00" }); } - - this.favicon.badge(notif, { bgColor }); + this.favicon.badge(notif); } /** diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index c5e34d71307..54aaea3ae19 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -63,6 +63,7 @@ import { blobIsAnimated } from "./utils/Image.ts"; const PHYS_HIDPI = [0x00, 0x00, 0x16, 0x25, 0x00, 0x00, 0x16, 0x25, 0x01]; export class UploadCanceledError extends Error {} +export class UploadFailedError extends Error {} interface IMediaConfig { "m.upload.size"?: number; @@ -355,12 +356,19 @@ export async function uploadFile( // Pass the encrypted data as a Blob to the uploader. const blob = new Blob([encryptResult.data]); - const { content_uri: url } = await matrixClient.uploadContent(blob, { - progressHandler, - abortController, - includeFilename: false, - type: "application/octet-stream", - }); + let url: string; + try { + ({ content_uri: url } = await matrixClient.uploadContent(blob, { + progressHandler, + abortController, + includeFilename: false, + type: "application/octet-stream", + })); + } catch (e) { + if (abortController.signal.aborted) throw new UploadCanceledError(); + console.error("Failed to upload file", e); + throw new UploadFailedError(); + } if (abortController.signal.aborted) throw new UploadCanceledError(); // If the attachment is encrypted then bundle the URL along with the information @@ -372,7 +380,14 @@ export async function uploadFile( } as EncryptedFile, }; } else { - const { content_uri: url } = await matrixClient.uploadContent(file, { progressHandler, abortController }); + let url: string; + try { + ({ content_uri: url } = await matrixClient.uploadContent(file, { progressHandler, abortController })); + } catch (e) { + if (abortController.signal.aborted) throw new UploadCanceledError(); + console.error("Failed to upload file", e); + throw new UploadFailedError(); + } if (abortController.signal.aborted) throw new UploadCanceledError(); // If the attachment isn't encrypted then include the URL directly. return { url }; @@ -570,7 +585,7 @@ export default class ContentMessages { const imageInfo = await infoForImageFile(matrixClient, roomId, file); Object.assign(content.info, imageInfo); } catch (e) { - if (e instanceof HTTPError) { + if (e instanceof UploadFailedError) { // re-throw to main upload error handler throw e; } diff --git a/src/CreateCrossSigning.ts b/src/CreateCrossSigning.ts index db9bc3e3fea..5f6f3e48aae 100644 --- a/src/CreateCrossSigning.ts +++ b/src/CreateCrossSigning.ts @@ -38,10 +38,10 @@ export async function createCrossSigning(cli: MatrixClient): Promise { export async function uiAuthCallback( matrixClient: MatrixClient, - makeRequest: (authData: AuthDict) => Promise, + makeRequest: (authData: AuthDict | null) => Promise, ): Promise { try { - await makeRequest({}); + await makeRequest(null); } catch (error) { if (!(error instanceof MatrixError) || !error.data || !error.data.flows) { // Not a UIA response @@ -64,7 +64,7 @@ export async function uiAuthCallback( }; const { finished } = Modal.createDialog(InteractiveAuthDialog, { - title: _t("encryption|bootstrap_title"), + title: "", matrixClient, makeRequest, aestheticsForStagePhases: { diff --git a/src/DateUtils.ts b/src/DateUtils.ts index e788ca09bfd..a5e22c08910 100644 --- a/src/DateUtils.ts +++ b/src/DateUtils.ts @@ -13,6 +13,8 @@ import { type Optional } from "matrix-events-sdk"; import { _t, getUserLanguage } from "./languageHandler"; import { getUserTimezone } from "./TimezoneHandler"; +export { formatSeconds } from "./shared-components/utils/DateUtils"; + export const MINUTE_MS = 60000; export const HOUR_MS = MINUTE_MS * 60; export const DAY_MS = HOUR_MS * 24; @@ -180,31 +182,6 @@ export function formatTime(date: Date, showTwelveHour = false, locale?: string): }).format(date); } -export function formatSeconds(inSeconds: number): string { - const isNegative = inSeconds < 0; - inSeconds = Math.abs(inSeconds); - - const hours = Math.floor(inSeconds / (60 * 60)) - .toFixed(0) - .padStart(2, "0"); - const minutes = Math.floor((inSeconds % (60 * 60)) / 60) - .toFixed(0) - .padStart(2, "0"); - const seconds = Math.floor((inSeconds % (60 * 60)) % 60) - .toFixed(0) - .padStart(2, "0"); - - let output = ""; - if (hours !== "00") output += `${hours}:`; - output += `${minutes}:${seconds}`; - - if (isNegative) { - output = "-" + output; - } - - return output; -} - export function formatTimeLeft(inSeconds: number): string { const hours = Math.floor(inSeconds / (60 * 60)).toFixed(0); const minutes = Math.floor((inSeconds % (60 * 60)) / 60).toFixed(0); diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index 751e71dd9f6..4fc4e9cf20f 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -15,7 +15,7 @@ import { type SyncState, ClientStoppedError, } from "matrix-js-sdk/src/matrix"; -import { logger as baseLogger, LogSpan } from "matrix-js-sdk/src/logger"; +import { logger as baseLogger, type BaseLogger, LogSpan } from "matrix-js-sdk/src/logger"; import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; import { type CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange"; import { secureRandomString } from "matrix-js-sdk/src/randomstring"; @@ -57,6 +57,11 @@ const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; */ export const BACKUP_DISABLED_ACCOUNT_DATA_KEY = "m.org.matrix.custom.backup_disabled"; +/** + * Account data key to indicate whether the user has chosen to enable or disable recovery. + */ +export const RECOVERY_ACCOUNT_DATA_KEY = "io.element.recovery"; + const logger = baseLogger.getChild("DeviceListener:"); export default class DeviceListener { @@ -97,6 +102,7 @@ export default class DeviceListener { this.client.on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated); this.client.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged); this.client.on(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged); + this.client.on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatusChanged); this.client.on(ClientEvent.AccountData, this.onAccountData); this.client.on(ClientEvent.Sync, this.onSync); this.client.on(RoomStateEvent.Events, this.onRoomStateEvents); @@ -132,7 +138,7 @@ export default class DeviceListener { this.dismissedThisDeviceToast = false; this.keyBackupInfo = null; this.keyBackupFetchedAt = null; - this.keyBackupStatusChecked = false; + this.cachedKeyBackupUploadActive = undefined; this.ourDeviceIdsAtStart = null; this.displayingToastsForDeviceIds = new Set(); this.client = undefined; @@ -157,6 +163,20 @@ export default class DeviceListener { this.recheck(); } + /** + * Set the account data "m.org.matrix.custom.backup_disabled" to { "disabled": true }. + */ + public async recordKeyBackupDisabled(): Promise { + await this.client?.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true }); + } + + /** + * Set the account data to indicate that recovery is disabled + */ + public async recordRecoveryDisabled(): Promise { + await this.client?.setAccountData(RECOVERY_ACCOUNT_DATA_KEY, { enabled: false }); + } + private async ensureDeviceIdsAtStartPopulated(): Promise { if (this.ourDeviceIdsAtStart === null) { this.ourDeviceIdsAtStart = await this.getDeviceIds(); @@ -192,6 +212,12 @@ export default class DeviceListener { this.recheck(); }; + private onKeyBackupStatusChanged = (): void => { + logger.info("Backup status changed"); + this.cachedKeyBackupUploadActive = undefined; + this.recheck(); + }; + private onCrossSingingKeysChanged = (): void => { this.recheck(); }; @@ -201,11 +227,14 @@ export default class DeviceListener { // * migrated SSSS to symmetric // * uploaded keys to secret storage // * completed secret storage creation + // * disabled key backup // which result in account data changes affecting checks below. if ( ev.getType().startsWith("m.secret_storage.") || ev.getType().startsWith("m.cross_signing.") || - ev.getType() === "m.megolm_backup.v1" + ev.getType() === "m.megolm_backup.v1" || + ev.getType() === BACKUP_DISABLED_ACCOUNT_DATA_KEY || + ev.getType() === RECOVERY_ACCOUNT_DATA_KEY ) { this.recheck(); } @@ -285,6 +314,7 @@ export default class DeviceListener { private async doRecheck(): Promise { if (!this.running || !this.client) return; // we have been stopped const logSpan = new LogSpan(logger, "check_" + secureRandomString(4)); + logSpan.debug("starting recheck..."); const cli = this.client; @@ -317,6 +347,9 @@ export default class DeviceListener { crossSigningStatus.privateKeysCachedLocally.userSigningKey; const defaultKeyId = await cli.secretStorage.getDefaultKeyId(); + const recoveryDisabled = await this.recheckRecoveryDisabled(cli); + + const recoveryIsOk = secretStorageReady || recoveryDisabled; const isCurrentDeviceTrusted = crossSigningReady && @@ -324,7 +357,15 @@ export default class DeviceListener { (await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified, ); - const allSystemsReady = crossSigningReady && secretStorageReady && allCrossSigningSecretsCached; + const keyBackupUploadActive = await this.isKeyBackupUploadActive(logSpan); + const backupDisabled = await this.recheckBackupDisabled(cli); + + // We warn if key backup upload is turned off and we have not explicitly + // said we are OK with that. + const keyBackupIsOk = keyBackupUploadActive || backupDisabled; + + const allSystemsReady = isCurrentDeviceTrusted && allCrossSigningSecretsCached && keyBackupIsOk && recoveryIsOk; + await this.reportCryptoSessionStateToAnalytics(cli); if (this.dismissedThisDeviceToast || allSystemsReady) { @@ -336,13 +377,8 @@ export default class DeviceListener { // make sure our keys are finished downloading await crypto.getUserDeviceInfo([cli.getSafeUserId()]); - if (!crossSigningReady) { - // This account is legacy and doesn't have cross-signing set up at all. - // Prompt the user to set it up. - logSpan.info("Cross-signing not ready: showing SET_UP_ENCRYPTION toast"); - showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); - } else if (!isCurrentDeviceTrusted) { - // cross signing is ready but the current device is not trusted: prompt the user to verify + if (!isCurrentDeviceTrusted) { + // the current device is not trusted: prompt the user to verify logSpan.info("Current device not verified: showing VERIFY_THIS_SESSION toast"); showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION); } else if (!allCrossSigningSecretsCached) { @@ -353,26 +389,35 @@ export default class DeviceListener { crossSigningStatus.privateKeysCachedLocally, ); showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC); + } else if (!keyBackupIsOk) { + logSpan.info("Key backup upload is unexpectedly turned off: showing TURN_ON_KEY_STORAGE toast"); + showSetupEncryptionToast(SetupKind.TURN_ON_KEY_STORAGE); } else if (defaultKeyId === null) { - // the user just hasn't set up 4S yet: prompt them to do so (unless they've explicitly said no to key storage) - const disabledEvent = cli.getAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY); - if (!disabledEvent?.getContent().disabled) { + // The user just hasn't set up 4S yet: if they have key + // backup, prompt them to turn on recovery too. (If not, they + // have explicitly opted out, so don't hassle them.) + if (recoveryDisabled) { + logSpan.info("Recovery disabled: no toast needed"); + hideSetupEncryptionToast(); + } else if (keyBackupUploadActive) { logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast"); showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY); } else { logSpan.info("No default 4S key but backup disabled: no toast needed"); + hideSetupEncryptionToast(); } } else { - // some other condition... yikes! Show the 'set up encryption' toast: this is what we previously did - // in 'other' situations. Possibly we should consider prompting for a full reset in this case? - logSpan.warn("Couldn't match encryption state to a known case: showing 'setup encryption' prompt", { + // 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", { crossSigningReady, secretStorageReady, allCrossSigningSecretsCached, isCurrentDeviceTrusted, defaultKeyId, }); - showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); + showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC_STORE); } } else { logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false"); @@ -443,6 +488,30 @@ export default class DeviceListener { this.displayingToastsForDeviceIds = newUnverifiedDeviceIds; } + /** + * Fetch the account data for `backup_disabled`. If this is the first time, + * fetch it from the server (in case the initial sync has not finished). + * Otherwise, fetch it from the store as normal. + */ + private async recheckBackupDisabled(cli: MatrixClient): Promise { + const backupDisabled = await cli.getAccountDataFromServer(BACKUP_DISABLED_ACCOUNT_DATA_KEY); + return !!backupDisabled?.disabled; + } + + /** + * Check whether the user has disabled recovery. If this is the first time, + * fetch it from the server (in case the initial sync has not finished). + * Otherwise, fetch it from the store as normal. + */ + private async recheckRecoveryDisabled(cli: MatrixClient): Promise { + const recoveryStatus = await cli.getAccountDataFromServer(RECOVERY_ACCOUNT_DATA_KEY); + // Recovery is disabled only if the `enabled` flag is set to `false`. + // If it is missing, or set to any other value, we consider it as + // not-disabled, and will prompt the user to create recovery (if + // missing). + return recoveryStatus?.enabled === false; + } + /** * Reports current recovery state to analytics. * Checks if the session is verified and if the recovery is correctly set up (i.e all secrets known locally and in 4S). @@ -512,18 +581,43 @@ export default class DeviceListener { * trigger an auto-rageshake). */ private checkKeyBackupStatus = async (): Promise => { - if (this.keyBackupStatusChecked || !this.client) { - return; + if (!(await this.isKeyBackupUploadActive(logger))) { + dis.dispatch({ action: Action.ReportKeyBackupNotEnabled }); } - const activeKeyBackupVersion = await this.client.getCrypto()?.getActiveSessionBackupVersion(); - // if key backup is enabled, no need to check this ever again (XXX: why only when it is enabled?) - this.keyBackupStatusChecked = !!activeKeyBackupVersion; + }; - if (!activeKeyBackupVersion) { - dis.dispatch({ action: Action.ReportKeyBackupNotEnabled }); + /** + * Is key backup enabled? Use a cached answer if we have one. + */ + private isKeyBackupUploadActive = async (logger: BaseLogger): Promise => { + if (!this.client) { + // To preserve existing behaviour, if there is no client, we + // pretend key backup upload is on. + // + // Someone looking to improve this code could try throwing an error + // here since we don't expect client to be undefined. + return true; + } + + const crypto = this.client.getCrypto(); + if (!crypto) { + // If there is no crypto, there is no key backup + return false; + } + + // If we've already cached the answer, return it. + if (this.cachedKeyBackupUploadActive !== undefined) { + return this.cachedKeyBackupUploadActive; } + + // Fetch the answer and cache it + const activeKeyBackupVersion = await crypto.getActiveSessionBackupVersion(); + this.cachedKeyBackupUploadActive = !!activeKeyBackupVersion; + logger.debug(`Key backup upload is ${this.cachedKeyBackupUploadActive ? "active" : "inactive"}`); + + return this.cachedKeyBackupUploadActive; }; - private keyBackupStatusChecked = false; + private cachedKeyBackupUploadActive: boolean | undefined = undefined; private onRecordClientInformationSettingChange: CallbackFn = ( _originalSettingName, diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts index 8b88a18075c..952d35e88d1 100644 --- a/src/IConfigOptions.ts +++ b/src/IConfigOptions.ts @@ -81,6 +81,7 @@ export interface IConfigOptions { }; mobile_guide_toast?: boolean; + mobile_guide_app_variant?: "element" | "element-classic" | "element-pro"; default_theme?: "light" | "dark" | string; // custom themes are strings default_country_code?: string; // ISO 3166 alpha2 country code diff --git a/src/Keyboard.ts b/src/Keyboard.ts index de7ab059c6f..5a7e7b59f10 100644 --- a/src/Keyboard.ts +++ b/src/Keyboard.ts @@ -79,3 +79,12 @@ export function isOnlyCtrlOrCmdKeyEvent(ev: React.KeyboardEvent | KeyboardEvent) return ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey; } } + +/** + * Checks if the given keyboard event is a modified key event (i.e., if any modifier keys are active). + * @param ev The keyboard event to check + * @returns True if the event is a modified key event, false otherwise + */ +export function isModifiedKeyEvent(ev: React.KeyboardEvent | KeyboardEvent): boolean { + return ev.metaKey || ev.altKey || ev.ctrlKey || ev.shiftKey; +} diff --git a/src/LegacyCallHandler.tsx b/src/LegacyCallHandler.tsx index 97cb4785120..e618fa62a01 100644 --- a/src/LegacyCallHandler.tsx +++ b/src/LegacyCallHandler.tsx @@ -112,6 +112,7 @@ export enum LegacyCallHandlerEvent { CallsChanged = "calls_changed", CallChangeRoom = "call_change_room", SilencedCallsChanged = "silenced_calls_changed", + ShownSidebarsChanged = "shown_sidebars_changed", CallState = "call_state", ProtocolSupport = "protocol_support", } @@ -120,6 +121,7 @@ type EventEmitterMap = { [LegacyCallHandlerEvent.CallsChanged]: (calls: Map) => void; [LegacyCallHandlerEvent.CallChangeRoom]: (call: MatrixCall) => void; [LegacyCallHandlerEvent.SilencedCallsChanged]: (calls: Set) => void; + [LegacyCallHandlerEvent.ShownSidebarsChanged]: (sidebarsShown: Map) => void; [LegacyCallHandlerEvent.CallState]: (mappedRoomId: string | null, status: CallState) => void; [LegacyCallHandlerEvent.ProtocolSupport]: () => void; }; @@ -144,6 +146,8 @@ export default class LegacyCallHandler extends TypedEventEmitter(); // callIds + private shownSidebars = new Map(); // callId (call) -> sidebar show + private backgroundAudio = new BackgroundAudio(); private playingSources: Record = {}; // Record them for stopping @@ -240,6 +244,15 @@ export default class LegacyCallHandler extends TypedEventEmitter { try { const protocols = await MatrixClientPeg.safeGet().getThirdpartyProtocols(); @@ -679,7 +692,7 @@ export default class LegacyCallHandler extends TypedEventEmitter { - SettingsStore.setValue("fallbackICEServerAllowed", null, SettingLevel.DEVICE, allow); - }, }, undefined, true, ); + + finished.then(([allow]) => { + SettingsStore.setValue("fallbackICEServerAllowed", null, SettingLevel.DEVICE, allow); + }); } private showMediaCaptureError(call: MatrixCall): void { diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index c1559886b47..dcd4ae9c004 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -469,12 +469,15 @@ type TryAgainFunction = () => void; * @param tryAgain OPTIONAL function to call on try again button from error dialog */ function onFailedDelegatedAuthLogin(description: string | ReactNode, tryAgain?: TryAgainFunction): void { - Modal.createDialog(ErrorDialog, { + const { finished } = Modal.createDialog(ErrorDialog, { title: _t("auth|oidc|error_title"), description, button: _t("action|try_again"), + }); + + finished.then(([shouldTryAgain]) => { // if we have a tryAgain callback, call it the primary 'try again' button was clicked in the dialog - onFinished: tryAgain ? (shouldTryAgain?: boolean) => shouldTryAgain && tryAgain() : undefined, + if (shouldTryAgain) tryAgain?.(); }); } @@ -618,6 +621,9 @@ export async function restoreSessionFromStorage(opts?: { ignoreGuest?: boolean } await getStoredSessionVars(); if (hasAccessToken && !accessToken) { + logger.warn( + "restoreSessionFromStorage: storage indicates we should have an access token, but we do not. Displaying StorageEvictedDialog", + ); await abortLogin(); } @@ -654,7 +660,7 @@ export async function restoreSessionFromStorage(opts?: { ignoreGuest?: boolean } freshLogin: freshLogin, }, false, - false, + freshLogin, ); return true; } else { @@ -820,6 +826,7 @@ async function doSetLoggedIn( // crypto store, we'll be generally confused when handling encrypted data. // Show a modal recommending a full reset of storage. if (results.dataInLocalStorage && results.cryptoInited && !results.dataInCryptoStore) { + logger.warn("doSetLoggedIn: StorageManager consistency check failed; displaying StorageEvictedDialog."); await abortLogin(); } @@ -1112,7 +1119,9 @@ export async function onLoggedOut(): Promise { * @param {object} opts Options for how to clear storage. * @returns {Promise} promise which resolves once the stores have been cleared */ -async function clearStorage(opts?: { deleteEverything?: boolean }): Promise { +export async function clearStorage(opts?: { deleteEverything?: boolean }): Promise { + logger.info(`Clearing storage, deleteEverything=${opts?.deleteEverything}`); + if (window.localStorage) { // get the currently defined device language, if set, so we can restore it later const language = SettingsStore.getValueAt(SettingLevel.DEVICE, "language", null, true, true); diff --git a/src/Modal.tsx b/src/Modal.tsx index ba5c6702bdd..e2873783ea3 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -10,7 +10,6 @@ Please see LICENSE files in the repository root for full details. import React, { StrictMode } from "react"; import { createRoot, type Root } from "react-dom/client"; import classNames from "classnames"; -import { type IDeferred, defer } from "matrix-js-sdk/src/utils"; import { TypedEventEmitter } from "matrix-js-sdk/src/matrix"; import { Glass, TooltipProvider } from "@vector-im/compound-web"; @@ -30,12 +29,25 @@ export type ComponentType = }> | React.ComponentType; -// Generic type which returns the props of the Modal component with the onFinished being optional. +/** + * The parameter types of the `onFinished` callback property exposed by the component which forms the + * body of the dialog. + * + * @typeParam C - The type of the React component which forms the body of the dialog. + */ +type OnFinishedParams = Parameters["onFinished"]>; + +/** + * The properties exposed by the `props` argument to {@link Modal.createDialog}: the same as + * those exposed by the underlying component, with the exception of `onFinished`, which is provided by + * `createDialog`. + * + * @typeParam C - The type of the React component which forms the body of the dialog. + */ export type ComponentProps = Defaultize< Omit, "onFinished">, C["defaultProps"] -> & - Partial, "onFinished">>; +>; export interface IModal { elem: React.ReactNode; @@ -43,15 +55,44 @@ export interface IModal { beforeClosePromise?: Promise; closeReason?: ModalCloseReason; onBeforeClose?(reason?: ModalCloseReason): Promise; - onFinished: ComponentProps["onFinished"]; - close(...args: Parameters["onFinished"]>): void; + + /** + * Run the {@link deferred} with the given arguments, and close this modal. + * + * This method is passed as the `onFinished` callback to the underlying component, + * as well as being returned by {@link Modal.createDialog} to the caller. + */ + close(...args: OnFinishedParams | []): void; + hidden?: boolean; - deferred?: IDeferred["onFinished"]>>; + + /** A deferred to resolve when the dialog closes, with the results as provided by + * the call to {@link close} (normally from the `onFinished` callback). + */ + deferred?: PromiseWithResolvers | []>; } +/** The result of {@link Modal.createDialog}. + * + * @typeParam C - The type of the React component which forms the body of the dialog. + */ export interface IHandle { - finished: Promise["onFinished"]>>; - close(...args: Parameters["onFinished"]>): void; + /** + * A promise which will resolve when the dialog closes. + * + * If the dialog body component calls the `onFinished` property, or the caller calls {@link close}, + * the promise resolves with an array holding the arguments to that call. + * + * If the dialog is closed by clicking in the background, the promise resolves with an empty array. + */ + finished: Promise | []>; + + /** + * A function which, if called, will close the dialog. + * + * @param args - Arguments to return to {@link finished}. + */ + close(...args: OnFinishedParams): void; } interface IOptions { @@ -164,7 +205,6 @@ export class ModalManager extends TypedEventEmitter["finished"]; } { const modal = { - onFinished: props?.onFinished, onBeforeClose: options?.onBeforeClose, className, @@ -196,8 +235,7 @@ export class ModalManager extends TypedEventEmitter; - // never call this from onFinished() otherwise it will loop - const [closeDialog, onFinishedProm] = this.getCloseFn(modal, props); + const [closeDialog, onFinishedProm] = this.getCloseFn(modal); // don't attempt to reuse the same AsyncWrapper for different dialogs, // otherwise we'll get confused. @@ -214,13 +252,10 @@ export class ModalManager extends TypedEventEmitter( - modal: IModal, - props?: ComponentProps, - ): [IHandle["close"], IHandle["finished"]] { - modal.deferred = defer["onFinished"]>>(); + private getCloseFn(modal: IModal): [IHandle["close"], IHandle["finished"]] { + modal.deferred = Promise.withResolvers | []>(); return [ - async (...args: Parameters["onFinished"]>): Promise => { + async (...args: OnFinishedParams): Promise => { if (modal.beforeClosePromise) { await modal.beforeClosePromise; } else if (modal.onBeforeClose) { @@ -232,7 +267,6 @@ export class ModalManager extends TypedEventEmitter= 0) { this.modals.splice(i, 1); @@ -280,7 +314,8 @@ export class ModalManager extends TypedEventEmitter import('./MyComponent'))` * - * @param props properties to pass to the displayed component. (We will also pass an 'onFinished' property.) + * @param props properties to pass to the displayed component. (We will also pass an `onFinished` property; when + * called, that property will close the dialog and return the results to the caller via {@link IHandle.finished}.) * * @param className CSS class to apply to the modal wrapper * @@ -295,7 +330,7 @@ export class ModalManager extends TypedEventEmitter( component: C, diff --git a/src/Registration.tsx b/src/Registration.tsx index ea0264fab35..22eb6e15ff5 100644 --- a/src/Registration.tsx +++ b/src/Registration.tsx @@ -62,14 +62,14 @@ export async function startAnyRegistrationFlow( , ] : [], - onFinished: (proceed) => { - if (proceed) { - dis.dispatch({ action: "start_login", screenAfterLogin: options.screen_after }); - } else if (options.go_home_on_cancel) { - dis.dispatch({ action: Action.ViewHomePage }); - } else if (options.go_welcome_on_cancel) { - dis.dispatch({ action: "view_welcome_page" }); - } - }, + }); + modal.finished.then(([proceed]) => { + if (proceed) { + dis.dispatch({ action: "start_login", screenAfterLogin: options.screen_after }); + } else if (options.go_home_on_cancel) { + dis.dispatch({ action: Action.ViewHomePage }); + } else if (options.go_welcome_on_cancel) { + dis.dispatch({ action: "view_welcome_page" }); + } }); } diff --git a/src/RoomAliasCache.ts b/src/RoomAliasCache.ts index fd611a7559f..ff57a70e6c0 100644 --- a/src/RoomAliasCache.ts +++ b/src/RoomAliasCache.ts @@ -6,6 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +type CacheResult = { roomId: string; viaServers: string[] }; + /** * This is meant to be a cache of room alias to room ID so that moving between * rooms happens smoothly (for example using browser back / forward buttons). @@ -16,12 +18,12 @@ Please see LICENSE files in the repository root for full details. * A similar thing could also be achieved via `pushState` with a state object, * but keeping it separate like this seems easier in case we do want to extend. */ -const aliasToIDMap = new Map(); +const cache = new Map(); -export function storeRoomAliasInCache(alias: string, id: string): void { - aliasToIDMap.set(alias, id); +export function storeRoomAliasInCache(alias: string, roomId: string, viaServers: string[]): void { + cache.set(alias, { roomId, viaServers }); } -export function getCachedRoomIDForAlias(alias: string): string | undefined { - return aliasToIDMap.get(alias); +export function getCachedRoomIdForAlias(alias: string): CacheResult | undefined { + return cache.get(alias); } diff --git a/src/RoomInvite.tsx b/src/RoomInvite.tsx index a02530a1cf8..feefdf72444 100644 --- a/src/RoomInvite.tsx +++ b/src/RoomInvite.tsx @@ -7,10 +7,9 @@ Please see LICENSE files in the repository root for full details. */ import React, { type ComponentProps } from "react"; -import { type Room, type MatrixEvent, type MatrixClient, type User, EventType } from "matrix-js-sdk/src/matrix"; -import { logger } from "matrix-js-sdk/src/logger"; +import { EventType, type MatrixClient, type MatrixEvent, type Room, type User } from "matrix-js-sdk/src/matrix"; -import MultiInviter, { type CompletionStates } from "./utils/MultiInviter"; +import MultiInviter, { type CompletionStates, type MultiInviterOptions } from "./utils/MultiInviter"; import Modal from "./Modal"; import { _t } from "./languageHandler"; import InviteDialog from "./components/views/dialogs/InviteDialog"; @@ -26,22 +25,24 @@ export interface IInviteResult { } /** - * Invites multiple addresses to a room - * Simpler interface to utils/MultiInviter but with - * no option to cancel. + * Invites multiple addresses to a room. + * + * Simpler interface to {@link MultiInviter}. + * + * Any failures are returned via the `states` in the result. * * @param {string} roomId The ID of the room to invite to * @param {string[]} addresses Array of strings of addresses to invite. May be matrix IDs or 3pids. - * @param {function} progressCallback optional callback, fired after each invite. + * @param options Options object. * @returns {Promise} Promise */ export async function inviteMultipleToRoom( client: MatrixClient, roomId: string, addresses: string[], - progressCallback?: () => void, + options: MultiInviterOptions = {}, ): Promise { - const inviter = new MultiInviter(client, roomId, progressCallback); + const inviter = new MultiInviter(client, roomId, options); return { states: await inviter.invite(addresses), inviter }; } @@ -89,26 +90,6 @@ export function isValid3pidInvite(event: MatrixEvent): boolean { return true; } -export function inviteUsersToRoom( - client: MatrixClient, - roomId: string, - userIds: string[], - progressCallback?: () => void, -): Promise { - return inviteMultipleToRoom(client, roomId, userIds, progressCallback) - .then((result) => { - const room = client.getRoom(roomId)!; - showAnyInviteErrors(result.states, room, result.inviter); - }) - .catch((err) => { - logger.error(err.stack); - Modal.createDialog(ErrorDialog, { - title: _t("invite|failed_title"), - description: err?.message ?? _t("invite|failed_generic"), - }); - }); -} - export function showAnyInviteErrors( states: CompletionStates, room: Room, diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index b497c57a10c..ecf895fd797 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -19,7 +19,6 @@ import AccessSecretStorageDialog, { type KeyParams, } from "./components/views/dialogs/security/AccessSecretStorageDialog"; import { ModuleRunner } from "./modules/ModuleRunner"; -import QuestionDialog from "./components/views/dialogs/QuestionDialog"; import InteractiveAuthDialog from "./components/views/dialogs/InteractiveAuthDialog"; // This stores the secret storage private keys in memory for the JS SDK. This is @@ -50,17 +49,6 @@ export class AccessCancelledError extends Error { } } -async function confirmToDismiss(): Promise { - const [sure] = await Modal.createDialog(QuestionDialog, { - title: _t("encryption|cancel_entering_passphrase_title"), - description: _t("encryption|cancel_entering_passphrase_description"), - danger: false, - button: _t("action|go_back"), - cancelButton: _t("action|cancel"), - }).finished; - return !sure; -} - function makeInputToKey( keyInfo: SecretStorage.SecretStorageKeyDescription, ): (keyParams: KeyParams) => Promise { @@ -134,17 +122,6 @@ async function getSecretStorageKey( return MatrixClientPeg.safeGet().secretStorage.checkKey(key, keyInfo); }, }, - /* className= */ undefined, - /* isPriorityModal= */ false, - /* isStaticModal= */ false, - /* options= */ { - onBeforeClose: async (reason): Promise => { - if (reason === "backgroundClick") { - return confirmToDismiss(); - } - return true; - }, - }, ); const [keyParams] = await finished; if (!keyParams) { @@ -199,10 +176,11 @@ export async function withSecretStorageKeyCache(func: () => Promise): Prom } export interface AccessSecretStorageOpts { - /** Reset secret storage even if it's already set up. */ + /** + * Reset secret storage even if it's already set up. + * @deprecated send the user to the Encryption settings tab to reset secret storage + */ forceReset?: boolean; - /** Create new cross-signing keys. Only applicable if `forceReset` is `true`. */ - resetCrossSigning?: boolean; } /** @@ -212,8 +190,8 @@ export interface AccessSecretStorageOpts { * provided function. * * Bootstrapping secret storage may take one of these paths: - * 1. Create secret storage from a passphrase and store cross-signing keys - * in secret storage. + * 1. (Only if `opts.forceReset` is set) create secret storage from a passphrase + * and store cross-signing keys in secret storage. * 2. Access existing secret storage by requesting passphrase and accessing * cross-signing keys as needed. * 3. All keys are loaded and there's nothing to do. @@ -222,6 +200,8 @@ export interface AccessSecretStorageOpts { * to ensure the user is prompted only once for their secret storage * passphrase. The cache is then cleared once the provided function completes. * + * Throws an error if secret storage is not set up (and `opts.forceReset` is not set) + * * @param {Function} [func] An operation to perform once secret storage has been * bootstrapped. Optional. * @param [opts] The options to use when accessing secret storage. @@ -242,16 +222,8 @@ async function doAccessSecretStorage(func: () => Promise, opts: AccessSecr throw new Error("End-to-end encryption is disabled - unable to access secret storage."); } - let createNew = false; if (opts.forceReset) { logger.debug("accessSecretStorage: resetting 4S"); - createNew = true; - } else if (!(await cli.secretStorage.hasKey())) { - logger.debug("accessSecretStorage: no 4S key configured, creating a new one"); - createNew = true; - } - - if (createNew) { // This dialog calls bootstrap itself after guiding the user through // passphrase creation. const { finished } = Modal.createDialog( @@ -274,6 +246,9 @@ async function doAccessSecretStorage(func: () => Promise, opts: AccessSecr if (!confirmed) { throw new Error("Secret storage creation canceled"); } + } else if (!(await cli.secretStorage.hasKey())) { + logger.debug("accessSecretStorage: no 4S key configured"); + throw new Error("Secret storage has not been created yet."); } else { logger.debug("accessSecretStorage: bootstrapCrossSigning"); await crypto.bootstrapCrossSigning({ diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index afbfeeca03e..f0d9085507b 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -60,6 +60,7 @@ import { deop, op } from "./slash-commands/op"; import { CommandCategories } from "./slash-commands/interface"; import { Command } from "./slash-commands/command"; import { goto, join } from "./slash-commands/join"; +import { manuallyVerifyDevice } from "./components/views/dialogs/ManualDeviceKeyVerificationDialog"; export { CommandCategories, Command }; @@ -147,7 +148,7 @@ export const Commands = [ command: "upgraderoom", args: "", description: _td("slash_command|upgraderoom"), - isEnabled: (cli) => !isCurrentLocalRoom(cli) && SettingsStore.getValue("developerMode"), + isEnabled: (cli) => !isCurrentLocalRoom(cli), runFn: function (cli, roomId, threadId, args) { if (args) { const room = cli.getRoom(roomId); @@ -663,6 +664,36 @@ export const Commands = [ category: CommandCategories.admin, renderingTypes: [TimelineRenderingType.Room], }), + new Command({ + command: "verify", + args: " ", + description: _td("slash_command|verify"), + runFn: function (cli, _roomId, _threadId, args) { + if (args) { + const matches = args.match(/^(\S+) +(\S+)$/); + if (matches) { + const deviceId = matches[1]; + const fingerprint = matches[2]; + + const { finished } = Modal.createDialog(QuestionDialog, { + title: _t("slash_command|manual_device_verification_confirm_title"), + description: _t("slash_command|manual_device_verification_confirm_description"), + button: _t("action|verify"), + danger: true, + }); + + return success( + finished.then(([confirmed]) => { + if (confirmed) manuallyVerifyDevice(cli, deviceId, fingerprint); + }), + ); + } + } + return reject(this.getUsage()); + }, + category: CommandCategories.advanced, + renderingTypes: [TimelineRenderingType.Room], + }), new Command({ command: "discardsession", description: _td("slash_command|discardsession"), diff --git a/src/SlidingSyncManager.ts b/src/SlidingSyncManager.ts index 88b839312d1..815e438da75 100644 --- a/src/SlidingSyncManager.ts +++ b/src/SlidingSyncManager.ts @@ -49,7 +49,7 @@ import { SlidingSyncState, } from "matrix-js-sdk/src/sliding-sync"; import { logger } from "matrix-js-sdk/src/logger"; -import { defer, sleep } from "matrix-js-sdk/src/utils"; +import { sleep } from "matrix-js-sdk/src/utils"; // how long to long poll for const SLIDING_SYNC_TIMEOUT_MS = 20 * 1000; @@ -184,7 +184,7 @@ export class SlidingSyncManager { public slidingSync?: SlidingSync; private client?: MatrixClient; - private configureDefer = defer(); + private configureDefer = Promise.withResolvers(); public static get instance(): SlidingSyncManager { return SlidingSyncManager.internalInstance; diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index b63e5b2a00e..60ada112e05 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -36,9 +36,9 @@ import { RoomSettingsTab } from "./components/views/dialogs/RoomSettingsDialog"; import AccessibleButton from "./components/views/elements/AccessibleButton"; import RightPanelStore from "./stores/right-panel/RightPanelStore"; import { highlightEvent, isLocationEvent } from "./utils/EventUtils"; -import { ElementCall } from "./models/Call"; import { getSenderName } from "./utils/event/getSenderName"; import PosthogTrackers from "./PosthogTrackers.ts"; +import { ElementCallEventType } from "./call-types.ts"; function getRoomMemberDisplayname(client: MatrixClient, event: MatrixEvent, userId = event.getSender()): string { const roomId = event.getRoomId(); @@ -572,8 +572,11 @@ function textForPinnedEvent(event: MatrixEvent, client: MatrixClient, allowJSX: const senderName = getSenderName(event); const roomId = event.getRoomId()!; - const pinned = event.getContent<{ pinned: string[] }>().pinned ?? []; - const previouslyPinned: string[] = event.getPrevContent().pinned ?? []; + const content = event.getContent<{ pinned: string[] }>(); + const prevContent = event.getPrevContent(); + + const pinned = Array.isArray(content.pinned) ? content.pinned : []; + const previouslyPinned: string[] = Array.isArray(prevContent.pinned) ? prevContent.pinned : []; const newlyPinned = pinned.filter((item) => previouslyPinned.indexOf(item) < 0); const newlyUnpinned = previouslyPinned.filter((item) => pinned.indexOf(item) < 0); @@ -922,7 +925,7 @@ for (const evType of ALL_RULE_TYPES) { } // Add both stable and unstable m.call events -for (const evType of ElementCall.CALL_EVENT_TYPE.names) { +for (const evType of ElementCallEventType.names) { stateHandlers[evType] = textForCallEvent; } diff --git a/src/Unread.ts b/src/Unread.ts index e8f4769e25f..d6a80a8f97e 100644 --- a/src/Unread.ts +++ b/src/Unread.ts @@ -39,7 +39,12 @@ export function eventTriggersUnreadCount(client: MatrixClient, ev: MatrixEvent): } if (ev.isRedacted()) return false; - return haveRendererForEvent(ev, client, false /* hidden messages should never trigger unread counts anyways */); + try { + return haveRendererForEvent(ev, client, false /* hidden messages should never trigger unread counts anyways */); + } catch (e) { + console.warn("Error determining if event should trigger unread count", e); + return false; // If we can't determine if the event should trigger an unread count, assume it does not. + } } export function doesRoomHaveUnreadMessages(room: Room, includeThreads: boolean): boolean { diff --git a/src/WorkerManager.ts b/src/WorkerManager.ts index a8a95ca727d..1d403be88c2 100644 --- a/src/WorkerManager.ts +++ b/src/WorkerManager.ts @@ -6,14 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { defer, type IDeferred } from "matrix-js-sdk/src/utils"; - import { type WorkerPayload } from "./workers/worker"; export class WorkerManager { private readonly worker: Worker; private seq = 0; - private pendingDeferredMap = new Map>(); + private pendingDeferredMap = new Map>(); public constructor(worker: Worker) { this.worker = worker; @@ -30,7 +28,7 @@ export class WorkerManager { public call(request: Request): Promise { const seq = this.seq++; - const deferred = defer(); + const deferred = Promise.withResolvers(); this.pendingDeferredMap.set(seq, deferred); this.worker.postMessage({ seq, ...request }); return deferred.promise; diff --git a/src/accessibility/KeyboardShortcuts.ts b/src/accessibility/KeyboardShortcuts.ts index da0097f4b27..21ea5abb02f 100644 --- a/src/accessibility/KeyboardShortcuts.ts +++ b/src/accessibility/KeyboardShortcuts.ts @@ -8,7 +8,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 { _td, type TranslationKey } from "../languageHandler"; +// Import i18n.tsx instead of languageHandler to avoid circular deps +import { _td, type TranslationKey } from "../shared-components/utils/i18n"; import { IS_MAC, IS_ELECTRON, Key } from "../Keyboard"; import { type IBaseSetting } from "../settings/Settings"; import { type KeyCombo } from "../KeyBindingsManager"; @@ -145,6 +146,7 @@ export enum KeyBindingAction { ArrowDown = "KeyBinding.arrowDown", Tab = "KeyBinding.tab", Comma = "KeyBinding.comma", + Save = "KeyBinding.save", /** Toggle visibility of hidden events */ ToggleHiddenEventVisibility = "KeyBinding.toggleHiddenEventVisibility", @@ -268,6 +270,7 @@ export const CATEGORIES: Record = { KeyBindingAction.ArrowRight, KeyBindingAction.ArrowDown, KeyBindingAction.Comma, + KeyBindingAction.Save, ], }, [CategoryName.NAVIGATION]: { @@ -521,7 +524,8 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = { [KeyBindingAction.GoToHome]: { default: { ctrlKey: true, - altKey: true, + altKey: !IS_MAC, + shiftKey: IS_MAC, key: Key.H, }, displayName: _td("keyboard|go_home_view"), @@ -586,7 +590,7 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = { default: { ctrlKey: true, shiftKey: true, - key: Key.H, + key: Key.J, }, displayName: _td("keyboard|toggle_hidden_events"), }, @@ -619,6 +623,13 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = { }, displayName: _td("keyboard|composer_redo"), }, + [KeyBindingAction.Save]: { + default: { + key: Key.S, + ctrlOrCmdKey: true, + }, + displayName: _td("keyboard|save"), + }, [KeyBindingAction.PreviousVisitedRoomOrSpace]: { default: { metaKey: IS_MAC, diff --git a/src/async-components/structures/ErrorView.tsx b/src/async-components/structures/ErrorView.tsx index 0f9a61781ed..5ce2a900a1c 100644 --- a/src/async-components/structures/ErrorView.tsx +++ b/src/async-components/structures/ErrorView.tsx @@ -10,7 +10,7 @@ import { Text, Heading, Button, Separator } from "@vector-im/compound-web"; import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-out"; import SdkConfig from "../../SdkConfig"; -import { Flex } from "../../components/utils/Flex"; +import { Flex } from "../../shared-components/utils/Flex"; import { _t } from "../../languageHandler"; import { Icon as AppleIcon } from "../../../res/themes/element/img/compound/apple.svg"; import { Icon as MicrosoftIcon } from "../../../res/themes/element/img/compound/microsoft.svg"; @@ -58,7 +58,7 @@ const MobileAppLinks: React.FC<{ googlePlayUrl?: string; fdroidUrl?: string; }> = ({ appleAppStoreUrl, googlePlayUrl, fdroidUrl }) => ( - + {appleAppStoreUrl && ( Apple App Store @@ -84,7 +84,7 @@ const DesktopAppLinks: React.FC<{ linuxUrl?: string; }> = ({ macOsUrl, win64Url, win64ArmUrl, linuxUrl }) => { return ( - + {macOsUrl && ( diff --git a/src/async-components/views/dialogs/security/CreateKeyBackupDialog.tsx b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.tsx deleted file mode 100644 index 0bc6fea2195..00000000000 --- a/src/async-components/views/dialogs/security/CreateKeyBackupDialog.tsx +++ /dev/null @@ -1,186 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. -Copyright 2018, 2019 New Vector Ltd - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import React, { type JSX } from "react"; -import { logger } from "matrix-js-sdk/src/logger"; - -import { MatrixClientPeg } from "../../../../MatrixClientPeg"; -import { _t } from "../../../../languageHandler"; -import { accessSecretStorage, withSecretStorageKeyCache } from "../../../../SecurityManager"; -import Spinner from "../../../../components/views/elements/Spinner"; -import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; -import DialogButtons from "../../../../components/views/elements/DialogButtons"; - -enum Phase { - BackingUp = "backing_up", - Done = "done", -} - -interface IProps { - onFinished(done?: boolean): void; -} - -interface IState { - phase: Phase; - passPhrase: string; - passPhraseValid: boolean; - passPhraseConfirm: string; - copied: boolean; - downloaded: boolean; - error?: boolean; -} - -/** - * Walks the user through the process of setting up e2e key backups to a new backup, and storing the decryption key in - * SSSS. - * - * Uses {@link accessSecretStorage}, which means that if 4S is not already configured, it will be bootstrapped (which - * involves displaying an {@link CreateSecretStorageDialog} so the user can enter a passphrase and/or download the 4S - * key). - */ -export default class CreateKeyBackupDialog extends React.PureComponent { - public constructor(props: IProps) { - super(props); - - this.state = { - phase: Phase.BackingUp, - passPhrase: "", - passPhraseValid: false, - passPhraseConfirm: "", - copied: false, - downloaded: false, - }; - } - - public componentDidMount(): void { - this.createBackup(); - } - - private createBackup = async (): Promise => { - this.setState({ - error: undefined, - }); - const cli = MatrixClientPeg.safeGet(); - try { - // Check if 4S already set up - const secretStorageAlreadySetup = await cli.secretStorage.hasKey(); - - if (!secretStorageAlreadySetup) { - // bootstrap secret storage; that will also create a backup version - await accessSecretStorage(async (): Promise => { - // do nothing, all is now set up correctly - }); - } else { - await withSecretStorageKeyCache(async () => { - const crypto = cli.getCrypto(); - if (!crypto) { - throw new Error("End-to-end encryption is disabled - unable to create backup."); - } - - // Before we reset the backup, let's make sure we can access secret storage, to - // reduce the chance of us getting into a broken state where we have an outdated - // secret in secret storage. - // `SecretStorage.get` will ask the user to enter their passphrase/key if necessary; - // it will then be cached for the actual backup reset operation. - await cli.secretStorage.get("m.megolm_backup.v1"); - - // We now know we can store the new backup key in secret storage, so it is safe to - // go ahead with the reset. - await crypto.resetKeyBackup(); - }); - } - - this.setState({ - phase: Phase.Done, - }); - } catch (e) { - logger.error("Error creating key backup", e); - // TODO: If creating a version succeeds, but backup fails, should we - // delete the version, disable backup, or do nothing? If we just - // disable without deleting, we'll enable on next app reload since - // it is trusted. - this.setState({ - error: true, - }); - } - }; - - private onCancel = (): void => { - this.props.onFinished(false); - }; - - private onDone = (): void => { - this.props.onFinished(true); - }; - - private renderBusyPhase(): JSX.Element { - return ( -

- ); - } - - private renderPhaseDone(): JSX.Element { - return ( -
-

{_t("settings|key_backup|backup_in_progress")}

- -
- ); - } - - private titleForPhase(phase: Phase): string { - switch (phase) { - case Phase.BackingUp: - return _t("settings|key_backup|backup_starting"); - case Phase.Done: - return _t("settings|key_backup|backup_success"); - default: - return _t("settings|key_backup|create_title"); - } - } - - public render(): React.ReactNode { - let content; - if (this.state.error) { - content = ( -
-

{_t("settings|key_backup|cannot_create_backup")}

- -
- ); - } else { - switch (this.state.phase) { - case Phase.BackingUp: - content = this.renderBusyPhase(); - break; - case Phase.Done: - content = this.renderPhaseDone(); - break; - } - } - - return ( - -
{content}
-
- ); - } -} diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx index 0d17ddaa39e..4ed369fb137 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx @@ -56,7 +56,6 @@ const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, interface IProps { forceReset?: boolean; - resetCrossSigning?: boolean; onFinished(ok?: boolean): void; } @@ -80,11 +79,12 @@ interface IState { * If the user already has a key backup, follows a "migration" flow (aka "Upgrade your encryption") which * prompts the user to enter their backup decryption password (a Curve25519 private key, possibly derived * from a passphrase), and uses that as the (AES) 4S encryption key. + * + * @deprecated send the user to EncryptionUserSettingsTab instead */ export default class CreateSecretStorageDialog extends React.PureComponent { public static defaultProps: Partial = { forceReset: false, - resetCrossSigning: false, }; private recoveryKey?: GeneratedSecretStorageKey; private recoveryKeyNode = createRef(); @@ -211,7 +211,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent => { const cli = MatrixClientPeg.safeGet(); const crypto = cli.getCrypto()!; - const { forceReset, resetCrossSigning } = this.props; + const { forceReset } = this.props; let backupInfo; // First, unless we know we want to do a reset, we see if there is an existing key backup @@ -246,13 +246,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent this.recoveryKey!, setupNewSecretStorage: true, }); - if (resetCrossSigning) { - logger.log("Resetting cross signing"); - await crypto.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: this.doBootstrapUIAuth, - setupNewCrossSigning: true, - }); - } logger.log("Resetting key backup"); await crypto.resetKeyBackup(); } else { diff --git a/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx index 9b9683b7985..a2fef4e1c92 100644 --- a/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx +++ b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx @@ -49,15 +49,8 @@ export default function NewRecoveryMethodDialog({ onFinished }: NewRecoveryMetho if (isKeyBackupEnabled) { onFinished(); } else { - Modal.createDialog( - RestoreKeyBackupDialog, - { - onFinished, - }, - undefined, - false, - true, - ); + const { finished } = Modal.createDialog(RestoreKeyBackupDialog, {}, undefined, false, true); + finished.then(onFinished); } } diff --git a/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.tsx b/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.tsx index 7e82ff722b9..7c3abf37b15 100644 --- a/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.tsx +++ b/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.tsx @@ -7,14 +7,15 @@ 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 React, { lazy } from "react"; +import React from "react"; import dis from "../../../../dispatcher/dispatcher"; import { _t } from "../../../../languageHandler"; -import Modal from "../../../../Modal"; import { Action } from "../../../../dispatcher/actions"; +import { UserTab } from "../../../../components/views/dialogs/UserTab"; import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; import DialogButtons from "../../../../components/views/elements/DialogButtons"; +import { type OpenToTabPayload } from "../../../../dispatcher/payloads/OpenToTabPayload"; interface IProps { onFinished(): void; @@ -28,13 +29,12 @@ export default class RecoveryMethodRemovedDialog extends React.PureComponent { this.props.onFinished(); - Modal.createDialog( - lazy(() => import("./CreateKeyBackupDialog")), - undefined, - undefined, - /* priority = */ false, - /* static = */ true, - ); + // Open the user settings dialog to the encryption tab and start the flow to reset encryption + const payload: OpenToTabPayload = { + action: Action.ViewUserSettings, + initialTabId: UserTab.Encryption, + }; + dis.dispatch(payload); }; public render(): React.ReactNode { diff --git a/src/audio/BackgroundAudio.ts b/src/audio/BackgroundAudio.ts index c90016eef91..eda10c0904d 100644 --- a/src/audio/BackgroundAudio.ts +++ b/src/audio/BackgroundAudio.ts @@ -48,6 +48,12 @@ export class BackgroundAudio { source.buffer = this.sounds[url]; source.loop = loop; source.connect(this.audioContext.destination); + + await this.audioContext.resume(); + source.onended = () => { + this.audioContext.suspend(); + }; + source.start(); return source; } diff --git a/src/audio/Playback.ts b/src/audio/Playback.ts index 58b8a23c22a..96930d43af8 100644 --- a/src/audio/Playback.ts +++ b/src/audio/Playback.ts @@ -9,14 +9,13 @@ Please see LICENSE files in the repository root for full details. import EventEmitter from "events"; import { SimpleObservable } from "matrix-widget-api"; import { logger } from "matrix-js-sdk/src/logger"; -import { defer } from "matrix-js-sdk/src/utils"; import { UPDATE_EVENT } from "../stores/AsyncStore"; import { arrayFastResample } from "../utils/arrays"; import { type IDestroyable } from "../utils/IDestroyable"; import { PlaybackClock } from "./PlaybackClock"; import { createAudioContext, decodeOgg } from "./compat"; -import { clamp } from "../utils/numbers"; +import { clamp } from "../shared-components/utils/numbers"; import { DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES } from "./consts"; import { PlaybackEncoder } from "../PlaybackEncoder"; @@ -158,42 +157,27 @@ export class Playback extends EventEmitter implements IDestroyable, PlaybackInte // 5mb logger.log("Audio file too large: processing through
diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 3686d52bcad..3bd2518c8ad 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -379,13 +379,14 @@ export default class LeftPanel extends React.Component { } public render(): React.ReactNode { + const useNewRoomList = SettingsStore.getValue("feature_new_room_list"); const containerClasses = classNames({ mx_LeftPanel: true, + mx_LeftPanel_newRoomList: useNewRoomList, mx_LeftPanel_minimized: this.props.isMinimized, }); const roomListClasses = classNames("mx_LeftPanel_actualRoomListContainer", "mx_AutoHideScrollbar"); - const useNewRoomList = SettingsStore.getValue("feature_new_room_list"); if (useNewRoomList) { return (
diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 9e236d1c2a4..a416ded127d 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -111,6 +111,7 @@ interface IState { backgroundImage?: string; } +const NEW_ROOM_LIST_MIN_WIDTH = 224; /** * This is what our MatrixChat shows when we are logged in. The precise view is * determined by the page_type property. @@ -259,10 +260,17 @@ class LoggedInView extends React.Component { private createResizer(): Resizer { let panelSize: number | null; let panelCollapsed: boolean; + const useNewRoomList = SettingsStore.getValue("feature_new_room_list"); + // TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel + const toggleSize = useNewRoomList ? NEW_ROOM_LIST_MIN_WIDTH : 206 - 50; + const collapseConfig: ICollapseConfig = { - // TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel - toggleSize: 206 - 50, + toggleSize, onCollapsed: (collapsed) => { + if (useNewRoomList) { + // The new room list does not support collapsing. + return; + } panelCollapsed = collapsed; if (collapsed) { dis.dispatch({ action: "hide_left_panel" }); @@ -279,11 +287,13 @@ class LoggedInView extends React.Component { this.props.resizeNotifier.startResizing(); }, onResizeStop: () => { - if (!panelCollapsed) window.localStorage.setItem("mx_lhs_size", "" + panelSize); + // Always save the lhs size for the new room list. + if (useNewRoomList || !panelCollapsed) window.localStorage.setItem("mx_lhs_size", "" + panelSize); this.props.resizeNotifier.stopResizing(); }, isItemCollapsed: (domNode) => { - return domNode.classList.contains("mx_LeftPanel_minimized"); + // New rooms list does not support collapsing. + return !useNewRoomList && domNode.classList.contains("mx_LeftPanel_minimized"); }, handler: this.resizeHandler.current ?? undefined, }; @@ -297,8 +307,11 @@ class LoggedInView extends React.Component { } private loadResizerPreferences(): void { + const useNewRoomList = SettingsStore.getValue("feature_new_room_list"); let lhsSize = parseInt(window.localStorage.getItem("mx_lhs_size")!, 10); - if (isNaN(lhsSize)) { + // If the user has not set a size, or for the new room list if the size is less than the minimum width, + // set a default size. + if (isNaN(lhsSize) || (useNewRoomList && lhsSize < NEW_ROOM_LIST_MIN_WIDTH)) { lhsSize = 204; } this.resizer?.forHandleWithId("lp-resizer")?.resize(lhsSize); @@ -697,10 +710,18 @@ class LoggedInView extends React.Component { "mx_MatrixChat--with-avatar": this.state.backgroundImage, }); + const useNewRoomList = SettingsStore.getValue("feature_new_room_list"); + + const leftPanelWrapperClasses = classNames({ + mx_LeftPanel_wrapper: true, + mx_LeftPanel_newRoomList: useNewRoomList, + }); + const audioFeedArraysForCalls = this.state.activeCalls.map((call) => { return ; }); + const shouldUseMinimizedUI = !useNewRoomList && this.props.collapseLhs; return (
{
- -
- + +
+ {!useNewRoomList && ( + + )} - + {!useNewRoomList && }
diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 83d6f5f54cc..aef4fcdaf0a 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -13,20 +13,21 @@ import { EventType, HttpApiEvent, type MatrixClient, - type MatrixEvent, + MatrixEvent, + MsgType, type RoomType, SyncState, type SyncStateData, type TimelineEvents, } from "matrix-js-sdk/src/matrix"; -import { defer, type IDeferred, type QueryDict } from "matrix-js-sdk/src/utils"; +import { type QueryDict } from "matrix-js-sdk/src/utils"; import { logger } from "matrix-js-sdk/src/logger"; import { throttle } from "lodash"; import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; import { TooltipProvider } from "@vector-im/compound-web"; - // what-input helps improve keyboard accessibility import "what-input"; +import sanitizeHtml from "sanitize-html"; import PosthogTrackers from "../../PosthogTrackers"; import { DecryptionFailureTracker } from "../../DecryptionFailureTracker"; @@ -50,6 +51,7 @@ import ThemeController from "../../settings/controllers/ThemeController"; import { startAnyRegistrationFlow } from "../../Registration"; import ResizeNotifier from "../../utils/ResizeNotifier"; import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils"; +import { calculateRoomVia, makeRoomPermalink } from "../../utils/permalinks/Permalinks"; import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher"; import { FontWatcher } from "../../settings/watchers/FontWatcher"; import { storeRoomAliasInCache } from "../../RoomAliasCache"; @@ -94,7 +96,6 @@ import VerificationRequestToast from "../views/toasts/VerificationRequestToast"; import PerformanceMonitor, { PerformanceEntryNames } from "../../performance"; import UIStore, { UI_EVENTS } from "../../stores/UIStore"; import SoftLogout from "./auth/SoftLogout"; -import { makeRoomPermalink } from "../../utils/permalinks/Permalinks"; import { copyPlaintext } from "../../utils/strings"; import { PosthogAnalytics } from "../../PosthogAnalytics"; import { initSentry } from "../../sentry"; @@ -107,6 +108,7 @@ import Views from "../../Views"; import { type FocusNextType, type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { type ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload"; import { type AfterLeaveRoomPayload } from "../../dispatcher/payloads/AfterLeaveRoomPayload"; +import { type AfterForgetRoomPayload } from "../../dispatcher/payloads/AfterForgetRoomPayload"; import { type DoAfterSyncPreparedPayload } from "../../dispatcher/payloads/DoAfterSyncPreparedPayload"; import { type ViewStartChatOrReusePayload } from "../../dispatcher/payloads/ViewStartChatOrReusePayload"; import { leaveRoomBehaviour } from "../../utils/leave-behaviour"; @@ -123,7 +125,7 @@ import { viewUserDeviceSettings } from "../../actions/handlers/viewUserDeviceSet import GenericToast from "../views/toasts/GenericToast"; import RovingSpotlightDialog from "../views/dialogs/spotlight/SpotlightDialog"; import { findDMForUser } from "../../utils/dm/findDMForUser"; -import { Linkify } from "../../HtmlUtils"; +import { getHtmlText, Linkify } from "../../HtmlUtils"; import { NotificationLevel } from "../../stores/notifications/NotificationLevel"; import { type UserTab } from "../views/dialogs/UserTab"; import { shouldSkipSetupEncryption } from "../../utils/crypto/shouldSkipSetupEncryption"; @@ -135,6 +137,11 @@ import { LoginSplashView } from "./auth/LoginSplashView"; import { cleanUpDraftsIfRequired } from "../../DraftCleaner"; import { InitialCryptoSetupStore } from "../../stores/InitialCryptoSetupStore"; import { setTheme } from "../../theme"; +import { type OpenForwardDialogPayload } from "../../dispatcher/payloads/OpenForwardDialogPayload"; +import { ShareFormat, type SharePayload } from "../../dispatcher/payloads/SharePayload"; +import Markdown from "../../Markdown"; +import { sanitizeHtmlParams } from "../../Linkify"; +import { isOnlyAdmin } from "../../utils/membership"; // legacy export export { default as Views } from "../../Views"; @@ -215,7 +222,7 @@ export default class MatrixChat extends React.PureComponent { }; private firstSyncComplete = false; - private firstSyncPromise: IDeferred; + private firstSyncPromise: PromiseWithResolvers; private screenAfterLogin?: IScreen; private tokenLogin?: boolean; @@ -231,6 +238,8 @@ export default class MatrixChat extends React.PureComponent { private readonly stores: SdkContextClass; private loadSessionAbortController = new AbortController(); + private sessionLoadStarted = false; + public constructor(props: IProps) { super(props); this.stores = SdkContextClass.instance; @@ -254,7 +263,7 @@ export default class MatrixChat extends React.PureComponent { // Used by _viewRoom before getting state from sync this.firstSyncComplete = false; - this.firstSyncPromise = defer(); + this.firstSyncPromise = Promise.withResolvers(); if (this.props.config.sync_timeline_limit) { MatrixClientPeg.opts.initialSyncLimit = this.props.config.sync_timeline_limit; @@ -463,15 +472,20 @@ export default class MatrixChat extends React.PureComponent { this.fontWatcher.start(); initSentry(SdkConfig.get("sentry")); + window.addEventListener("resize", this.onWindowResized); - if (!checkSessionLockFree()) { - // another instance holds the lock; confirm its theft before proceeding - setTimeout(() => this.setState({ view: Views.CONFIRM_LOCK_THEFT }), 0); - } else { - this.startInitSession(); + // Once we start loading the MatrixClient, we can't stop, even if MatrixChat gets unmounted (as it does + // in React's Strict Mode). So, start loading the session now, but only if this MatrixChat was not previously + // mounted. + if (!this.sessionLoadStarted) { + this.sessionLoadStarted = true; + if (!checkSessionLockFree()) { + // another instance holds the lock; confirm its theft before proceeding + setTimeout(() => this.setState({ view: Views.CONFIRM_LOCK_THEFT }), 0); + } else { + this.startInitSession(); + } } - - window.addEventListener("resize", this.onWindowResized); } public componentDidUpdate(prevProps: IProps, prevState: IState): void { @@ -779,6 +793,9 @@ export default class MatrixChat extends React.PureComponent { case Action.ViewHomePage: this.viewHome(payload.justRegistered); break; + case Action.Share: + this.viewShare(payload.format, payload.msg); + break; case Action.ViewStartChatOrReuse: this.chatCreateOrReuse(payload.user_id); break; @@ -1009,7 +1026,7 @@ export default class MatrixChat extends React.PureComponent { presentedId = theAlias; // Store display alias of the presented room in cache to speed future // navigation. - storeRoomAliasInCache(theAlias, room.roomId); + storeRoomAliasInCache(theAlias, room.roomId, calculateRoomVia(room)); } // Store this as the ID of the last room accessed. This is so that we can @@ -1114,6 +1131,58 @@ export default class MatrixChat extends React.PureComponent { }); } + private viewShare(format: ShareFormat, msg: string): void { + // Wait for the first sync so we can present possible rooms to share into + this.firstSyncPromise.promise.then(() => { + this.notifyNewScreen("share"); + let rawEvent; + switch (format) { + case ShareFormat.Html: { + rawEvent = { + type: "m.room.message", + content: { + msgtype: MsgType.Text, + body: getHtmlText(msg), + format: "org.matrix.custom.html", + formatted_body: sanitizeHtml(msg, sanitizeHtmlParams), + }, + origin_server_ts: Date.now(), + }; + break; + } + case ShareFormat.Markdown: { + const html = new Markdown(msg).toHTML({ externalLinks: true }); + rawEvent = { + type: "m.room.message", + content: { + msgtype: MsgType.Text, + body: msg, + format: "org.matrix.custom.html", + formatted_body: html, + }, + origin_server_ts: Date.now(), + }; + break; + } + default: + rawEvent = { + type: "m.room.message", + content: { + msgtype: MsgType.Text, + body: msg, + }, + origin_server_ts: Date.now(), + }; + } + const event = new MatrixEvent(rawEvent); + dis.dispatch({ + action: Action.OpenForwardDialog, + event: event, + permalinkCreator: null, + }); + }); + } + private async createRoom(defaultPublic = false, defaultName?: string, type?: RoomType): Promise { const modal = Modal.createDialog(CreateRoomDialog, { type, @@ -1194,29 +1263,22 @@ export default class MatrixChat extends React.PureComponent { const client = MatrixClientPeg.get(); if (client && roomToLeave) { - const plEvent = roomToLeave.currentState.getStateEvents(EventType.RoomPowerLevels, ""); - const plContent = plEvent ? plEvent.getContent() : {}; - const userLevels = plContent.users || {}; - const currentUserLevel = userLevels[client.getUserId()!]; - const userLevelValues = Object.values(userLevels); - if (userLevelValues.every((x) => typeof x === "number")) { + // If the user is the only user with highest power level + if (isOnlyAdmin(roomToLeave)) { + const userLevelValues = roomToLeave.getJoinedMembers().map((m) => m.powerLevel); + const maxUserLevel = Math.max(...(userLevelValues as number[])); - // If the user is the only user with highest power level - if ( - maxUserLevel === currentUserLevel && - userLevelValues.lastIndexOf(maxUserLevel) == userLevelValues.indexOf(maxUserLevel) - ) { - const warning = - maxUserLevel >= 100 - ? _t("leave_room_dialog|room_leave_admin_warning") - : _t("leave_room_dialog|room_leave_mod_warning"); - warnings.push( - - {" " /* Whitespace, otherwise the sentences get smashed together */} - {warning} - , - ); - } + + const warning = + maxUserLevel >= 100 + ? _t("leave_room_dialog|room_leave_admin_warning") + : _t("leave_room_dialog|room_leave_mod_warning"); + warnings.push( + + {" " /* Whitespace, otherwise the sentences get smashed together */} + {warning} + , + ); } } @@ -1229,7 +1291,7 @@ export default class MatrixChat extends React.PureComponent { const warnings = this.leaveRoomWarnings(roomId); const isSpace = roomToLeave?.isSpaceRoom(); - Modal.createDialog(QuestionDialog, { + const { finished } = Modal.createDialog(QuestionDialog, { title: isSpace ? _t("space|leave_dialog_action") : _t("action|leave_room"), description: ( @@ -1245,16 +1307,17 @@ export default class MatrixChat extends React.PureComponent { ), button: _t("action|leave"), danger: warnings.length > 0, - onFinished: async (shouldLeave) => { - if (shouldLeave) { - await leaveRoomBehaviour(cli, roomId); + }); - dis.dispatch({ - action: Action.AfterLeaveRoom, - room_id: roomId, - }); - } - }, + finished.then(async ([shouldLeave]) => { + if (shouldLeave) { + await leaveRoomBehaviour(cli, roomId); + + dis.dispatch({ + action: Action.AfterLeaveRoom, + room_id: roomId, + }); + } }); } @@ -1268,10 +1331,12 @@ export default class MatrixChat extends React.PureComponent { dis.dispatch({ action: Action.ViewHomePage }); } - // We have to manually update the room list because the forgotten room will not - // be notified to us, therefore the room list will have no other way of knowing - // the room is forgotten. - if (room) RoomListStore.instance.manualRoomUpdate(room, RoomUpdateCause.RoomRemoved); + if (room) { + // Legacy room list store needs to be told to manually remove this room + RoomListStore.instance.manualRoomUpdate(room, RoomUpdateCause.RoomRemoved); + // New room list store will remove the room on the following dispatch + dis.dispatch({ action: Action.AfterForgetRoom, room }); + } }) .catch((err) => { const errCode = err.errcode || _td("error|unknown_error_code"); @@ -1470,11 +1535,11 @@ export default class MatrixChat extends React.PureComponent { // since we're about to start the client and therefore about to do the first sync // We resolve the existing promise with the new one to update any existing listeners if (!this.firstSyncComplete) { - const firstSyncPromise = defer(); + const firstSyncPromise = Promise.withResolvers(); this.firstSyncPromise.resolve(firstSyncPromise.promise); this.firstSyncPromise = firstSyncPromise; } else { - this.firstSyncPromise = defer(); + this.firstSyncPromise = Promise.withResolvers(); } this.firstSyncComplete = false; const cli = MatrixClientPeg.safeGet(); @@ -1558,7 +1623,7 @@ export default class MatrixChat extends React.PureComponent { }); }); cli.on(HttpApiEvent.NoConsent, function (message, consentUri) { - Modal.createDialog( + const { finished } = Modal.createDialog( QuestionDialog, { title: _t("terms|tac_title"), @@ -1569,16 +1634,16 @@ export default class MatrixChat extends React.PureComponent { ), button: _t("terms|tac_button"), cancelButton: _t("action|dismiss"), - onFinished: (confirmed) => { - if (confirmed) { - const wnd = window.open(consentUri, "_blank")!; - wnd.opener = null; - } - }, }, undefined, true, ); + finished.then(([confirmed]) => { + if (confirmed) { + const wnd = window.open(consentUri, "_blank")!; + wnd.opener = null; + } + }); }); DecryptionFailureTracker.instance @@ -1738,6 +1803,20 @@ export default class MatrixChat extends React.PureComponent { dis.dispatch({ action: Action.CreateChat, }); + } else if (screen === "share") { + if (params && params["msg"] !== undefined) { + dis.dispatch({ + action: Action.Share, + msg: params["msg"], + format: params["format"], + }); + } + // if we weren't already coming at this from an existing screen + // and we're logged in, then explicitly default to home. + // if we're not logged in, then the login flow will do the right thing. + if (!this.state.currentRoomId && !this.state.currentUserId) { + this.viewHome(); + } } else if (screen === "settings") { dis.fire(Action.ViewUserSettings); } else if (screen === "welcome") { diff --git a/src/components/structures/PipContainer.tsx b/src/components/structures/PipContainer.tsx index e46267c149e..18e8c526dbb 100644 --- a/src/components/structures/PipContainer.tsx +++ b/src/components/structures/PipContainer.tsx @@ -245,6 +245,7 @@ class PipContainerInner extends React.Component { secondaryCall={this.state.secondaryCall} pipMode={pipMode} onResize={onResize} + sidebarShown={false} /> )); } diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx index 21e2a2ee71f..f05fd58d82d 100644 --- a/src/components/structures/RightPanel.tsx +++ b/src/components/structures/RightPanel.tsx @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type ChangeEvent } from "react"; +import React from "react"; import { type Room, type RoomState, RoomStateEvent, RoomMember, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { throttle } from "lodash"; @@ -15,7 +15,7 @@ import dis from "../../dispatcher/dispatcher"; import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases"; import RightPanelStore from "../../stores/right-panel/RightPanelStore"; import MatrixClientContext from "../../contexts/MatrixClientContext"; -import RoomSummaryCard from "../views/right_panel/RoomSummaryCard"; +import RoomSummaryCardView from "../views/right_panel/RoomSummaryCardView"; import WidgetCard from "../views/right_panel/WidgetCard"; import UserInfo from "../views/right_panel/UserInfo"; import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo"; @@ -34,6 +34,7 @@ import { Action } from "../../dispatcher/actions"; import { type XOR } from "../../@types/common"; import ExtensionsCard from "../views/right_panel/ExtensionsCard"; import MemberListView from "../views/rooms/MemberList/MemberListView"; +import { _t } from "../../languageHandler"; interface BaseProps { overwriteCard?: IRightPanelCard; // used to display a custom card and ignoring the RightPanelStore (used for UserView) @@ -49,8 +50,9 @@ interface RoomlessProps extends BaseProps { interface RoomProps extends BaseProps { room: Room; permalinkCreator: RoomPermalinkCreator; - onSearchChange?: (e: ChangeEvent) => void; + onSearchChange?: (term: string) => void; onSearchCancel?: () => void; + searchTerm?: string; } type Props = XOR; @@ -63,6 +65,7 @@ interface IState { export default class RightPanel extends React.Component { public static contextType = MatrixClientContext; declare public context: React.ContextType; + private ref = React.createRef(); public constructor(props: Props) { super(props); @@ -81,6 +84,7 @@ export default class RightPanel extends React.Component { public componentDidMount(): void { this.context.on(RoomStateEvent.Members, this.onRoomStateMember); RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); + this.ref.current?.focus(); } public componentWillUnmount(): void { @@ -118,7 +122,13 @@ export default class RightPanel extends React.Component { }; private onRightPanelStoreUpdate = (): void => { - this.setState({ ...(RightPanel.getDerivedStateFromProps(this.props) as IState) }); + const oldPhase = this.state.phase; + const newState = RightPanel.getDerivedStateFromProps(this.props) as IState; + this.setState({ ...newState }); + + if (oldPhase !== newState.phase) { + this.ref.current?.focus(); + } }; private onClose = (): void => { @@ -254,12 +264,13 @@ export default class RightPanel extends React.Component { case RightPanelPhases.RoomSummary: if (!!this.props.room) { card = ( - ); @@ -280,7 +291,14 @@ export default class RightPanel extends React.Component { } return ( -
- ); - const link = makeUserPermalink(MatrixClientPeg.safeGet().getSafeUserId()); - footer = ( -
-

{_t("invite|send_link_prompt")}

- makeUserPermalink(MatrixClientPeg.safeGet().getSafeUserId())}> - - {link} - - -
- ); } else if (this.props.kind === InviteKind.Invite) { const roomId = this.props.roomId; const room = MatrixClientPeg.get()?.getRoom(roomId); const isSpace = room?.isSpaceRoom(); - title = isSpace - ? _t("invite|to_space", { - spaceName: room?.name || _t("common|unnamed_space"), - }) - : _t("invite|to_room", { - roomName: room?.name || _t("common|unnamed_room"), - }); let helpTextUntranslated; if (isSpace) { @@ -1382,31 +1422,6 @@ export default class InviteDialog extends React.PureComponent - - - {_t("action|cancel")} - - - {_t("action|transfer")} - -
- ); } const goButton = @@ -1415,134 +1430,168 @@ export default class InviteDialog extends React.PureComponent {buttonText} ); - let results: React.ReactNode | null = null; - let onlyOneThreepidNote: React.ReactNode | null = null; - - if (!this.canInviteMore() || (this.hasFilterAtLeastOneEmail() && !this.canInviteThirdParty())) { - // We are in DM case here, because of the checks in canInviteMore() / canInviteThirdParty(). - onlyOneThreepidNote =
{_t("invite|email_limit_one")}
; - } else { - results = ( -
- {this.renderSection("recents")} - {this.renderSection("suggestions")} - {extraSection} -
- ); - } - - const usersSection = ( + return (

{helpText}

{this.renderEditor()} -
- {goButton} - {spinner} -
+ {goButton}
- {this.renderIdentityServerWarning()} -
{this.state.errorText}
- {onlyOneThreepidNote} - {results} - {footer} + {this.state.busy ? : this.renderSuggestions()}
); + } - let dialogContent; - if (this.props.kind === InviteKind.CallTransfer) { - const tabs: NonEmptyArray> = [ - new Tab( - TabId.UserDirectory, - _td("invite|transfer_user_directory_tab"), - "mx_InviteDialog_userDirectoryIcon", - usersSection, - ), - ]; - - const backspaceButton = ; - - // Only show the backspace button if the field has content - let dialPadField; - if (this.state.dialPadValue.length !== 0) { - dialPadField = ( - - ); - } else { - dialPadField = ( - - ); - } + /** + * Render the complete dialog, given this is not a call transfer dialog. + * + * See also: {@link renderCallTransferDialog}. + */ + private renderRegularDialog(): React.ReactNode { + let title; + if (this.props.kind === InviteKind.Dm) { + title = _t("space|add_existing_room_space|dm_heading"); + } else if (this.props.kind === InviteKind.Invite) { + const roomId = this.props.roomId; + const room = MatrixClientPeg.get()?.getRoom(roomId); + const isSpace = room?.isSpaceRoom(); + title = isSpace + ? _t("invite|to_space", { + spaceName: room?.name || _t("common|unnamed_space"), + }) + : _t("invite|to_room", { + roomName: room?.name || _t("common|unnamed_room"), + }); + } - const dialPadSection = ( -
-
{dialPadField}
- -
- ); - tabs.push( - new Tab( - TabId.DialPad, - _td("invite|transfer_dial_pad_tab"), - "mx_InviteDialog_dialPadIcon", - dialPadSection, - ), - ); - dialogContent = ( - - - tabs={tabs} - activeTabId={this.state.currentTabId} - tabLocation={TabLocation.TOP} - onChange={this.onTabChange} - /> - {consultConnectSection} - + return ( + +
{this.renderMainTab()}
+
+ ); + } + + /** + * Render the complete call transfer dialog. + * + * See also: {@link renderRegularDialog}. + */ + private renderCallTransferDialog(): React.ReactNode { + const usersSection = this.renderMainTab(); + + const tabs: NonEmptyArray> = [ + new Tab( + TabId.UserDirectory, + _td("invite|transfer_user_directory_tab"), + "mx_InviteDialog_userDirectoryIcon", + usersSection, + ), + ]; + + const backspaceButton = ; + + // Only show the backspace button if the field has content + let dialPadField; + if (this.state.dialPadValue.length !== 0) { + dialPadField = ( + ); } else { - dialogContent = ( - - {usersSection} - {consultConnectSection} - + dialPadField = ( + ); } + const dialPadSection = ( +
+
{dialPadField}
+ +
+ ); + tabs.push( + new Tab(TabId.DialPad, _td("invite|transfer_dial_pad_tab"), "mx_InviteDialog_dialPadIcon", dialPadSection), + ); + + const consultConnectSection = ( +
+ + + {_t("action|cancel")} + + + {_t("action|transfer")} + +
+ ); + + const dialogContent = ( + + + tabs={tabs} + activeTabId={this.state.currentTabId} + tabLocation={TabLocation.TOP} + onChange={this.onTabChange} + /> + {consultConnectSection} + + ); + return (
{dialogContent}
); } + + public render(): React.ReactNode { + if (this.props.kind === InviteKind.CallTransfer) { + return this.renderCallTransferDialog(); + } else { + return this.renderRegularDialog(); + } + } } diff --git a/src/components/views/dialogs/InviteProgressBody.tsx b/src/components/views/dialogs/InviteProgressBody.tsx new file mode 100644 index 00000000000..a61c0d5922a --- /dev/null +++ b/src/components/views/dialogs/InviteProgressBody.tsx @@ -0,0 +1,24 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; + +import InlineSpinner from "../elements/InlineSpinner"; +import { _t } from "../../../languageHandler"; + +/** The common body of components that show the progress of sending room invites. */ +const InviteProgressBody: React.FC = () => { + return ( +
+ +

{_t("invite|progress|preparing")}

+ {_t("invite|progress|dont_close")} +
+ ); +}; + +export default InviteProgressBody; diff --git a/src/components/views/dialogs/InviteProgressDialog.tsx b/src/components/views/dialogs/InviteProgressDialog.tsx new file mode 100644 index 00000000000..fe62afa8d8b --- /dev/null +++ b/src/components/views/dialogs/InviteProgressDialog.tsx @@ -0,0 +1,38 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; + +import Modal from "../../../Modal.tsx"; +import InviteProgressBody from "./InviteProgressBody.tsx"; + +/** A Modal dialog that pops up while room invites are being sent. */ +const InviteProgressDialog: React.FC = (_) => { + return ; +}; + +/** + * Open the invite progress dialog. + * + * Returns a callback which will close the dialog again. + */ +export function openInviteProgressDialog(): () => void { + const onBeforeClose = async (reason?: string): Promise => { + // Inhibit closing via background click + return reason != "backgroundClick"; + }; + + const { close } = Modal.createDialog( + InviteProgressDialog, + /* props */ {}, + /* className */ undefined, + /* isPriorityModal */ false, + /* isStaticModal */ false, + { onBeforeClose }, + ); + return close; +} diff --git a/src/components/views/dialogs/LeaveSpaceDialog.tsx b/src/components/views/dialogs/LeaveSpaceDialog.tsx index e81606db797..7796bf4f615 100644 --- a/src/components/views/dialogs/LeaveSpaceDialog.tsx +++ b/src/components/views/dialogs/LeaveSpaceDialog.tsx @@ -15,23 +15,13 @@ import BaseDialog from "../dialogs/BaseDialog"; import SpaceStore from "../../../stores/spaces/SpaceStore"; import SpaceChildrenPicker from "../spaces/SpaceChildrenPicker"; import { filterBoolean } from "../../../utils/arrays"; +import { isOnlyAdmin } from "../../../utils/membership"; interface IProps { space: Room; onFinished(leave: boolean, rooms?: Room[]): void; } -const isOnlyAdmin = (room: Room): boolean => { - const userId = room.client.getSafeUserId(); - if (room.getMember(userId)?.powerLevelNorm !== 100) { - return false; // user is not an admin - } - return room.getJoinedMembers().every((member) => { - // return true if every other member has a lower power level (we are highest) - return member.userId === userId || member.powerLevelNorm < 100; - }); -}; - const LeaveSpaceDialog: React.FC = ({ space, onFinished }) => { const spaceChildren = useMemo(() => { const roomSet = new Set(SpaceStore.instance.getSpaceFilteredRoomIds(space.roomId)); diff --git a/src/components/views/dialogs/LogoutDialog.tsx b/src/components/views/dialogs/LogoutDialog.tsx index 6e000ef6311..830e5fb5898 100644 --- a/src/components/views/dialogs/LogoutDialog.tsx +++ b/src/components/views/dialogs/LogoutDialog.tsx @@ -13,9 +13,11 @@ import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import Modal from "../../../Modal"; import dis from "../../../dispatcher/dispatcher"; +import { type OpenToTabPayload } from "../../../dispatcher/payloads/OpenToTabPayload"; +import { Action } from "../../../dispatcher/actions"; +import { UserTab } from "../../../components/views/dialogs/UserTab"; import { _t } from "../../../languageHandler"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import RestoreKeyBackupDialog from "./security/RestoreKeyBackupDialog"; import QuestionDialog from "./QuestionDialog"; import BaseDialog from "./BaseDialog"; import Spinner from "../elements/Spinner"; @@ -138,26 +140,12 @@ export default class LogoutDialog extends React.Component { }; private onSetRecoveryMethodClick = (): void => { - if (this.state.backupStatus === BackupStatus.SERVER_BACKUP_BUT_DISABLED) { - // A key backup exists for this account, but the creating device is not - // verified, so restore the backup which will give us the keys from it and - // allow us to trust it (ie. upload keys to it) - Modal.createDialog( - RestoreKeyBackupDialog, - undefined, - undefined, - /* priority = */ false, - /* static = */ true, - ); - } else { - Modal.createDialog( - lazy(() => import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog")), - undefined, - undefined, - /* priority = */ false, - /* static = */ true, - ); - } + // Open the user settings dialog to the encryption tab and start the flow to reset encryption + const payload: OpenToTabPayload = { + action: Action.ViewUserSettings, + initialTabId: UserTab.Encryption, + }; + dis.dispatch(payload); // close dialog this.props.onFinished(true); @@ -190,22 +178,13 @@ export default class LogoutDialog extends React.Component {
); - let setupButtonCaption; - if (this.state.backupStatus === BackupStatus.SERVER_BACKUP_BUT_DISABLED) { - setupButtonCaption = _t("settings|security|key_backup_connect"); - } else { - // if there's an error fetching the backup info, we'll just assume there's - // no backup for the purpose of the button caption - setupButtonCaption = _t("auth|logout_dialog|use_key_backup"); - } - const dialogContent = (
{description}
+Copyright 2017 Vector Creations Ltd +Copyright 2016 OpenMarket Ltd + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import React, { type ChangeEvent, type JSX, useCallback, useState } from "react"; +import { type MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { _t, UserFriendlyError } from "../../../languageHandler"; +import { getDeviceCryptoInfo } from "../../../utils/crypto/deviceInfo"; +import QuestionDialog from "./QuestionDialog"; +import Modal from "../../../Modal"; +import InfoDialog from "./InfoDialog"; +import Field from "../elements/Field"; +import ErrorDialog from "./ErrorDialog"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; + +interface Props { + onFinished(confirm?: boolean): void; +} + +/** + * A dialog to allow us to verify devices logged in with clients that can't do + * the verification themselves. Intended for use as a dev tool. + * + * Requires entering the fingerprint ("session key") of the device in an attempt + * to prevent users being tricked into verifying a malicious device. + */ +export function ManualDeviceKeyVerificationDialog({ onFinished }: Readonly): JSX.Element { + const [deviceId, setDeviceId] = useState(""); + const [fingerprint, setFingerprint] = useState(""); + + const client = MatrixClientPeg.safeGet(); + + const onDialogFinished = useCallback( + async (confirm: boolean) => { + if (confirm) { + await manuallyVerifyDevice(client, deviceId, fingerprint); + } + onFinished(confirm); + }, + [client, deviceId, fingerprint, onFinished], + ); + + const onDeviceIdChange = useCallback((e: ChangeEvent) => { + setDeviceId(e.target.value); + }, []); + + const onFingerprintChange = useCallback((e: ChangeEvent) => { + setFingerprint(e.target.value); + }, []); + + const body = ( +
+

{_t("encryption|verification|manual|text")}

+
+ + +
+
+ ); + + return ( + + ); +} + +/** + * Check the supplied fingerprint matches the fingerprint ("session key") of the + * device with the supplied device ID, and if so, mark the device as verified. + */ +export async function manuallyVerifyDevice(client: MatrixClient, deviceId: string, fingerprint: string): Promise { + try { + await doManuallyVerifyDevice(client, deviceId, fingerprint); + + // Tell the user we verified everything + Modal.createDialog(InfoDialog, { + title: _t("encryption|verification|manual|success_title"), + description: ( +
+

{_t("encryption|verification|manual|success_description", { deviceId })}

+
+ ), + }); + } catch (e: any) { + // Display an error + const error = e instanceof UserFriendlyError ? e.translatedMessage : e.toString(); + Modal.createDialog(ErrorDialog, { + title: _t("encryption|verification|manual|failure_title"), + description: ( +
+

{_t("encryption|verification|manual|failure_description", { deviceId, error })}

+
+ ), + }); + } +} + +async function doManuallyVerifyDevice(client: MatrixClient, deviceId: string, fingerprint: string): Promise { + const userId = client.getUserId(); + if (!userId) { + throw new UserFriendlyError("encryption|verification|manual|no_userid", { + cause: undefined, + }); + } + + const crypto = client.getCrypto(); + if (!crypto) { + throw new UserFriendlyError("encryption|verification|manual|no_crypto"); + } + + const device = await getDeviceCryptoInfo(client, userId, deviceId); + if (!device) { + throw new UserFriendlyError("encryption|verification|manual|no_device", { + deviceId, + cause: undefined, + }); + } + const deviceTrust = await crypto.getDeviceVerificationStatus(userId, deviceId); + + if (deviceTrust?.isVerified()) { + if (device.getFingerprint() === fingerprint) { + throw new UserFriendlyError("encryption|verification|manual|already_verified", { + deviceId, + cause: undefined, + }); + } else { + throw new UserFriendlyError("encryption|verification|manual|already_verified_and_wrong_fingerprint", { + deviceId, + cause: undefined, + }); + } + } + + if (device.getFingerprint() !== fingerprint) { + const fprint = device.getFingerprint(); + throw new UserFriendlyError("encryption|verification|manual|wrong_fingerprint", { + fprint, + deviceId, + fingerprint, + cause: undefined, + }); + } + + // We've passed all the checks - do the device verification + await crypto.crossSignDevice(deviceId); +} diff --git a/src/components/views/dialogs/MessageEditHistoryDialog.tsx b/src/components/views/dialogs/MessageEditHistoryDialog.tsx index b4850130dbf..72b11bc4e13 100644 --- a/src/components/views/dialogs/MessageEditHistoryDialog.tsx +++ b/src/components/views/dialogs/MessageEditHistoryDialog.tsx @@ -8,7 +8,6 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX } from "react"; import { type MatrixEvent, EventType, RelationType, type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; -import { defer } from "matrix-js-sdk/src/utils"; import { logger } from "matrix-js-sdk/src/logger"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; @@ -58,7 +57,7 @@ export default class MessageEditHistoryDialog extends React.PureComponent(); + const { resolve, reject, promise } = Promise.withResolvers(); let result: Awaited>; try { diff --git a/src/components/views/dialogs/ResetIdentityDialog.tsx b/src/components/views/dialogs/ResetIdentityDialog.tsx new file mode 100644 index 00000000000..b946ab1d79a --- /dev/null +++ b/src/components/views/dialogs/ResetIdentityDialog.tsx @@ -0,0 +1,49 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX } from "react"; + +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { ResetIdentityBody, type ResetIdentityBodyVariant } from "../settings/encryption/ResetIdentityBody"; + +interface ResetIdentityDialogProps { + /** + * Called when the dialog is complete. + * + * `ResetIdentityDialog` expects this to be provided by `Modal.createDialog`, and that it will close the dialog. + */ + onFinished: () => void; + + /** + * Called when the identity is reset (before onFinished is called). + */ + onReset: () => void; + + /** + * Which variant of this dialog to show. + */ + variant: ResetIdentityBodyVariant; +} + +/** + * The dialog for resetting the identity of the current user. + */ +export function ResetIdentityDialog({ onFinished, onReset, variant }: ResetIdentityDialogProps): JSX.Element { + const matrixClient = MatrixClientPeg.safeGet(); + + const onResetWrapper: () => void = () => { + onReset(); + // Close the dialog + onFinished(); + }; + return ( + + + + ); +} diff --git a/src/components/views/dialogs/SessionRestoreErrorDialog.tsx b/src/components/views/dialogs/SessionRestoreErrorDialog.tsx index 26baef9f2a7..edf93558ff0 100644 --- a/src/components/views/dialogs/SessionRestoreErrorDialog.tsx +++ b/src/components/views/dialogs/SessionRestoreErrorDialog.tsx @@ -31,13 +31,13 @@ export default class SessionRestoreErrorDialog extends React.Component { }; private onClearStorageClick = (): void => { - Modal.createDialog(QuestionDialog, { + const { finished } = Modal.createDialog(QuestionDialog, { title: _t("action|sign_out"), description:
{_t("error|session_restore|clear_storage_description")}
, button: _t("action|sign_out"), danger: true, - onFinished: this.props.onFinished, }); + finished.then(([ok]) => this.props.onFinished(ok)); }; private onRefreshClick = (): void => { diff --git a/src/components/views/dialogs/SetEmailDialog.tsx b/src/components/views/dialogs/SetEmailDialog.tsx index 57d8ed12d44..5628df33ab6 100644 --- a/src/components/views/dialogs/SetEmailDialog.tsx +++ b/src/components/views/dialogs/SetEmailDialog.tsx @@ -66,12 +66,12 @@ export default class SetEmailDialog extends React.Component { this.addThreepid = new AddThreepid(MatrixClientPeg.safeGet()); this.addThreepid.addEmailAddress(emailAddress).then( () => { - Modal.createDialog(QuestionDialog, { + const { finished } = Modal.createDialog(QuestionDialog, { title: _t("auth|set_email|verification_pending_title"), description: _t("auth|set_email|verification_pending_description"), button: _t("action|continue"), - onFinished: this.onEmailDialogFinished, }); + finished.then(([ok]) => this.onEmailDialogFinished(ok)); }, (err) => { this.setState({ emailBusy: false }); @@ -89,7 +89,7 @@ export default class SetEmailDialog extends React.Component { this.props.onFinished(false); }; - private onEmailDialogFinished = (ok: boolean): void => { + private onEmailDialogFinished = (ok?: boolean): void => { if (ok) { this.verifyEmailAddress(); } else { @@ -115,12 +115,12 @@ export default class SetEmailDialog extends React.Component { _t("settings|general|error_email_verification") + " " + _t("auth|set_email|verification_pending_description"); - Modal.createDialog(QuestionDialog, { + const { finished } = Modal.createDialog(QuestionDialog, { title: _t("auth|set_email|verification_pending_title"), description: message, button: _t("action|continue"), - onFinished: this.onEmailDialogFinished, }); + finished.then(([ok]) => this.onEmailDialogFinished(ok)); } else { logger.error("Unable to verify email address: " + err); Modal.createDialog(ErrorDialog, { diff --git a/src/components/views/dialogs/UserSettingsDialog.tsx b/src/components/views/dialogs/UserSettingsDialog.tsx index 90f4e28e811..b3bf0ef7748 100644 --- a/src/components/views/dialogs/UserSettingsDialog.tsx +++ b/src/components/views/dialogs/UserSettingsDialog.tsx @@ -7,6 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +import { ClientEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { Toast } from "@vector-im/compound-web"; import React, { type JSX, useState } from "react"; import UserProfileIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-profile"; @@ -18,7 +19,7 @@ import KeyboardIcon from "@vector-im/compound-design-tokens/assets/web/icons/key import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key"; import SidebarIcon from "@vector-im/compound-design-tokens/assets/web/icons/sidebar"; import MicOnIcon from "@vector-im/compound-design-tokens/assets/web/icons/mic-on"; -import WorkspaceIcon from "@vector-im/compound-design-tokens/assets/web/icons/workspace"; +import SpaceIcon from "@vector-im/compound-design-tokens/assets/web/icons/space"; import LockIcon from "@vector-im/compound-design-tokens/assets/web/icons/lock"; import LabsIcon from "@vector-im/compound-design-tokens/assets/web/icons/labs"; import BlockIcon from "@vector-im/compound-design-tokens/assets/web/icons/block"; @@ -48,6 +49,7 @@ import { UserTab } from "./UserTab"; import { type NonEmptyArray } from "../../../@types/common"; import { SDKContext, type SdkContextClass } from "../../../contexts/SDKContext"; import { useSettingValue } from "../../../hooks/useSettings"; +import { NoChange, useEventEmitterAsyncState, type AsyncStateCallbackResult } from "../../../hooks/useEventEmitter"; import { ToastContext, useActiveToast } from "../../../contexts/ToastContext"; import { EncryptionUserSettingsTab, type State } from "../settings/tabs/user/EncryptionUserSettingsTab"; @@ -108,6 +110,26 @@ export default function UserSettingsDialog(props: IProps): JSX.Element { const [showMsc4108QrCode, setShowMsc4108QrCode] = useState(props.showMsc4108QrCode); const [initialEncryptionState, setInitialEncryptionState] = useState(props.initialEncryptionState); + // If the user doesn't have Recovery set up (no default Secret Storage key), + // we show an indicator on the Encryption tab. + const showSetupRecoveryIndicator = useEventEmitterAsyncState( + props.sdkContext.client, + ClientEvent.AccountData, + async (event?: MatrixEvent): AsyncStateCallbackResult => { + if (event === undefined || event.getType() === "m.secret_storage.default_key") { + const client = props.sdkContext.client; + if (!client) { + return false; + } + + return !(await client.secretStorage.getDefaultKeyId()); + } + return new NoChange(); + }, + [], + false, + ); + const getTabs = (): NonEmptyArray> => { const tabs: Tab[] = []; @@ -192,7 +214,7 @@ export default function UserSettingsDialog(props: IProps): JSX.Element { new Tab( UserTab.RichPresence, _td("settings|rich_presence|title"), - , + , , "UserSettingsRichPresence", ), @@ -215,6 +237,7 @@ export default function UserSettingsDialog(props: IProps): JSX.Element { , , "UserSettingsEncryption", + showSetupRecoveryIndicator ? "mx_SettingsDialog_tabLabelsAlert" : undefined, ), ); diff --git a/src/components/views/dialogs/devtools/Crypto.tsx b/src/components/views/dialogs/devtools/Crypto.tsx index a05c415f00e..e491c00a733 100644 --- a/src/components/views/dialogs/devtools/Crypto.tsx +++ b/src/components/views/dialogs/devtools/Crypto.tsx @@ -12,6 +12,8 @@ import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext import BaseTool from "./BaseTool"; import { useAsyncMemo } from "../../../../hooks/useAsyncMemo"; import { _t } from "../../../../languageHandler"; +import Modal from "../../../../Modal"; +import { ManualDeviceKeyVerificationDialog } from "../ManualDeviceKeyVerificationDialog"; interface KeyBackupProps { /** @@ -31,6 +33,16 @@ export function Crypto({ onBack }: KeyBackupProps): JSX.Element { <> + + + ) : ( {_t("devtools|crypto|crypto_not_available")} @@ -254,3 +266,39 @@ function getCrossSigningStatus(crossSigningReady: boolean, crossSigningPrivateKe return _t("devtools|crypto|cross_signing_not_ready"); } + +/** + * A component that displays information about the current session. + */ +function Session(): JSX.Element { + const matrixClient = useMatrixClientContext(); + const sessionData = useAsyncMemo(async () => { + const crypto = matrixClient.getCrypto()!; + const keys = await crypto.getOwnDeviceKeys(); + return { + fingerprint: keys.ed25519, + deviceId: matrixClient.deviceId, + }; + }, [matrixClient]); + + // Show a spinner while loading + if (sessionData === undefined) { + return ; + } + + return ( + + {_t("devtools|crypto|session")} + + + + + + + + + + +
{_t("devtools|crypto|device_id")}{sessionData.deviceId}
{_t("devtools|crypto|session_fingerprint")}{sessionData.fingerprint}
+ ); +} diff --git a/src/components/views/dialogs/devtools/RoomState.tsx b/src/components/views/dialogs/devtools/RoomState.tsx index 0a41743372e..1f33fc5eea6 100644 --- a/src/components/views/dialogs/devtools/RoomState.tsx +++ b/src/components/views/dialogs/devtools/RoomState.tsx @@ -19,6 +19,7 @@ import FilteredList from "./FilteredList"; import Spinner from "../../elements/Spinner"; import SyntaxHighlight from "../../elements/SyntaxHighlight"; import { useAsyncMemo } from "../../../../hooks/useAsyncMemo"; +import LabelledToggleSwitch from "../../elements/LabelledToggleSwitch"; export const StateEventEditor: React.FC = ({ mxEvent, onBack }) => { const context = useContext(DevtoolsContext); @@ -114,6 +115,7 @@ const RoomStateExplorerEventType: React.FC = ({ eventType, onBa const [query, setQuery] = useState(""); const [event, setEvent] = useState(null); const [history, setHistory] = useState(false); + const [showEmptyState, setShowEmptyState] = useState(true); const events = context.room.currentState.events.get(eventType)!; @@ -149,10 +151,17 @@ const RoomStateExplorerEventType: React.FC = ({ eventType, onBa return ( - {Array.from(events.entries()).map(([stateKey, ev]) => ( - setEvent(ev)} /> - ))} + {Array.from(events.entries()) + .filter(([_, ev]) => showEmptyState || Object.keys(ev.getContent()).length > 0) + .map(([stateKey, ev]) => ( + setEvent(ev)} /> + ))} + ); }; diff --git a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx index 01d7203b68c..da9a67a6443 100644 --- a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx +++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx @@ -6,27 +6,18 @@ 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 { Button, PasswordInput } from "@vector-im/compound-web"; +import LockSolidIcon from "@vector-im/compound-design-tokens/assets/web/icons/lock-solid"; import { debounce } from "lodash"; import classNames from "classnames"; import React, { type ChangeEvent, type FormEvent } from "react"; -import { logger } from "matrix-js-sdk/src/logger"; -import { decodeRecoveryKey } from "matrix-js-sdk/src/crypto-api"; import { type SecretStorage } from "matrix-js-sdk/src/matrix"; -import { MatrixClientPeg } from "../../../../MatrixClientPeg"; -import Field from "../../elements/Field"; -import AccessibleButton, { type ButtonEvent } from "../../elements/AccessibleButton"; +import { Flex } from "../../../../shared-components/utils/Flex"; import { _t } from "../../../../languageHandler"; -import { accessSecretStorage } from "../../../../SecurityManager"; -import Modal from "../../../../Modal"; -import DialogButtons from "../../elements/DialogButtons"; +import { EncryptionCard } from "../../settings/encryption/EncryptionCard"; +import { EncryptionCardButtons } from "../../settings/encryption/EncryptionCardButtons"; import BaseDialog from "../BaseDialog"; -import { chromeFileInputFix } from "../../../../utils/BrowserWorkarounds"; - -// Maximum acceptable size of a key file. It's 59 characters including the spaces we encode, -// so this should be plenty and allow for people putting extra whitespace in the file because -// maybe that's a thing people would do? -const KEY_FILE_MAX_SIZE = 128; // Don't shout at the user that their key is invalid every time they type a key: wait a short time const VALIDATION_THROTTLE_MS = 200; @@ -34,27 +25,33 @@ const VALIDATION_THROTTLE_MS = 200; export type KeyParams = { passphrase?: string; recoveryKey?: string }; interface IProps { + /** + * Information about the Secret Storage key that we want to get. + */ keyInfo: SecretStorage.SecretStorageKeyDescription; + /** + * Callback to check whether the given key is correct. + */ checkPrivateKey: (k: KeyParams) => Promise; + /** + * Callback for when the user is done with this dialog. `result` will + * contain information about the key that was entered, or will be `false` if + * the user cancelled. + */ onFinished(result?: false | KeyParams): void; } interface IState { + //! The recovery key/phrase that the user entered recoveryKey: string; - recoveryKeyValid: boolean | null; + //! Is the recovery key/phrase correct? `null` means no key/phrase has been entered recoveryKeyCorrect: boolean | null; - recoveryKeyFileError: boolean | null; - forceRecoveryKey: boolean; - passPhrase: string; - keyMatches: boolean | null; - resetting: boolean; } /* * Access Secure Secret Storage by requesting the user's passphrase. */ export default class AccessSecretStorageDialog extends React.PureComponent { - private fileUpload = React.createRef(); private inputRef = React.createRef(); public constructor(props: IProps) { @@ -62,372 +59,163 @@ export default class AccessSecretStorageDialog extends React.PureComponent { - if (this.state.resetting) { - this.setState({ resetting: false }); - } this.props.onFinished(false); }; - private onUseRecoveryKeyClick = (): void => { - this.setState({ - forceRecoveryKey: true, - }); - }; - private validateRecoveryKeyOnChange = debounce(async (): Promise => { await this.validateRecoveryKey(this.state.recoveryKey); }, VALIDATION_THROTTLE_MS); - private async validateRecoveryKey(recoveryKey: string): Promise { + /** + * Checks whether the security key/phrase is correct. + * + * Sets `state.recoveryKeyCorrect` accordingly, and if the key/phrase is + * correct, returns a `KeyParams` structure. + */ + private async validateRecoveryKey(recoveryKey: string): Promise { + recoveryKey = recoveryKey.trim(); + if (recoveryKey === "") { this.setState({ - recoveryKeyValid: null, recoveryKeyCorrect: null, }); return; } + const hasPassphrase = this.props.keyInfo?.passphrase?.salt && this.props.keyInfo?.passphrase?.iterations; + + // If the user has a passphrase, we want to try validating it both as a + // key and as a passphrase. We first try to validate it as a key, since + // that check is faster. + try { - const cli = MatrixClientPeg.safeGet(); - const decodedKey = decodeRecoveryKey(recoveryKey); - const correct = await cli.secretStorage.checkKey(decodedKey, this.props.keyInfo); - this.setState({ - recoveryKeyValid: true, - recoveryKeyCorrect: correct, - }); - } catch { - this.setState({ - recoveryKeyValid: false, - recoveryKeyCorrect: false, - }); + const input = { recoveryKey }; + const recoveryKeyCorrect = await this.props.checkPrivateKey(input); + if (recoveryKeyCorrect) { + this.setState({ recoveryKeyCorrect }); + return input; + } + } catch {} + + if (hasPassphrase) { + try { + const input = { passphrase: recoveryKey }; + const recoveryKeyCorrect = await this.props.checkPrivateKey(input); + if (recoveryKeyCorrect) { + this.setState({ recoveryKeyCorrect }); + return input; + } + } catch {} } + + this.setState({ + recoveryKeyCorrect: false, + }); } private onRecoveryKeyChange = (ev: ChangeEvent): void => { this.setState({ recoveryKey: ev.target.value, - recoveryKeyFileError: null, }); - // also clear the file upload control so that the user can upload the same file - // the did before (otherwise the onchange wouldn't fire) - if (this.fileUpload.current) this.fileUpload.current.value = ""; - - // We don't use Field's validation here because a) we want it in a separate place rather - // than in a tooltip and b) we want it to display feedback based on the uploaded file - // as well as the text box. Ideally we would refactor Field's validation logic so we could + // We don't use Field's validation here because we want it in a separate place rather + // than in a tooltip. Ideally we would refactor Field's validation logic so we could // re-use some of it. this.validateRecoveryKeyOnChange(); }; - private onRecoveryKeyFileChange = async (ev: ChangeEvent): Promise => { - if (!ev.target.files?.length) return; - - const f = ev.target.files[0]; - - if (f.size > KEY_FILE_MAX_SIZE) { - this.setState({ - recoveryKeyFileError: true, - recoveryKeyCorrect: false, - recoveryKeyValid: false, - }); - } else { - const contents = await f.text(); - // test it's within the base58 alphabet. We could be more strict here, eg. require the - // right number of characters, but it's really just to make sure that what we're reading is - // text because we'll put it in the text field. - if (/^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz\s]+$/.test(contents)) { - const recoveryKey = contents.trim(); - this.setState({ - recoveryKeyFileError: null, - recoveryKey, - }); - await this.validateRecoveryKey(recoveryKey); - } else { - this.setState({ - recoveryKeyFileError: true, - recoveryKeyCorrect: false, - recoveryKeyValid: false, - recoveryKey: "", - }); - } - } - }; - - private onRecoveryKeyFileUploadClick = (): void => { - this.fileUpload.current?.click(); - }; - - private onPassPhraseNext = async (ev: FormEvent | React.MouseEvent): Promise => { + private onRecoveryKeyNext = async (ev: FormEvent | React.MouseEvent): Promise => { ev.preventDefault(); - if (this.state.passPhrase.length <= 0) { - this.inputRef.current?.focus(); - return; - } + const keyParams = await this.validateRecoveryKey(this.state.recoveryKey); - this.setState({ keyMatches: null }); - const input = { passphrase: this.state.passPhrase }; - const keyMatches = await this.props.checkPrivateKey(input); - if (keyMatches) { - this.props.onFinished(input); + if (keyParams !== undefined) { + this.props.onFinished(keyParams); } else { - this.setState({ keyMatches }); this.inputRef.current?.focus(); } }; - private onRecoveryKeyNext = async (ev: FormEvent | React.MouseEvent): Promise => { - ev.preventDefault(); + private getRecoveryKeyFeedback(): React.ReactNode | null { + let validationText: string; + let classes: string | undefined; - if (!this.state.recoveryKeyValid) return; - - this.setState({ keyMatches: null }); - const input = { recoveryKey: this.state.recoveryKey }; - const keyMatches = await this.props.checkPrivateKey(input); - if (keyMatches) { - this.props.onFinished(input); + if (this.state.recoveryKeyCorrect) { + // The recovery key is good. Empty feedback. + validationText = "\xA0"; //   + } else if (this.state.recoveryKeyCorrect === null) { + // The input element is empty. Tell the user they can also use a passphrase. + validationText = _t("encryption|access_secret_storage_dialog|alternatives"); } else { - this.setState({ keyMatches }); - } - }; - - private onPassPhraseChange = (ev: ChangeEvent): void => { - this.setState({ - passPhrase: ev.target.value, - keyMatches: null, - }); - }; - - private onResetAllClick = (ev: ButtonEvent): void => { - ev.preventDefault(); - this.setState({ resetting: true }); - }; - - private onConfirmResetAllClick = async (): Promise => { - // Hide ourselves so the user can interact with the reset dialogs. - // We don't conclude the promise chain (onFinished) yet to avoid confusing - // any upstream code flows. - // - // Note: this will unmount us, so don't call `setState` or anything in the - // rest of this function. - Modal.toggleCurrentDialogVisibility(); - - try { - // Force reset secret storage (which resets the key backup) - await accessSecretStorage( - async (): Promise => { - // Now we can indicate that the user is done pressing buttons, finally. - // Upstream flows will detect the new secret storage, key backup, etc and use it. - this.props.onFinished({}); - }, - { forceReset: true, resetCrossSigning: true }, - ); - } catch (e) { - logger.error(e); - this.props.onFinished(false); + // The entered key is not (yet) correct. Tell them so. + validationText = _t("encryption|access_secret_storage_dialog|key_validation_text|wrong_security_key"); + classes = classNames({ + "mx_AccessSecretStorageDialog_recoveryKeyFeedback": true, + "mx_AccessSecretStorageDialog_recoveryKeyFeedback--invalid": true, + }); } - }; - private getKeyValidationText(): string { - if (this.state.recoveryKeyFileError) { - return _t("encryption|access_secret_storage_dialog|key_validation_text|wrong_file_type"); - } else if (this.state.recoveryKeyCorrect) { - return _t("encryption|access_secret_storage_dialog|key_validation_text|recovery_key_is_correct"); - } else if (this.state.recoveryKeyValid) { - return _t("encryption|access_secret_storage_dialog|key_validation_text|wrong_security_key"); - } else if (this.state.recoveryKeyValid === null) { - return ""; - } else { - return _t("encryption|access_secret_storage_dialog|key_validation_text|invalid_security_key"); - } + return ( + + {validationText} + + ); } public render(): React.ReactNode { - const hasPassphrase = this.props.keyInfo?.passphrase?.salt && this.props.keyInfo?.passphrase?.iterations; - - const resetLine = ( - - {_t("encryption|reset_all_button", undefined, { - a: (sub) => ( - - {sub} - - ), - })} - - ); - - let content; - let title; - let titleClass; - if (this.state.resetting) { - title = _t("encryption|access_secret_storage_dialog|reset_title"); - titleClass = ["mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_resetBadge"]; - content = ( -
-

{_t("encryption|access_secret_storage_dialog|reset_warning_1")}

-

{_t("encryption|access_secret_storage_dialog|reset_warning_2")}

- -
- ); - } else if (hasPassphrase && !this.state.forceRecoveryKey) { - title = _t("encryption|access_secret_storage_dialog|security_phrase_title"); - titleClass = ["mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_securePhraseTitle"]; - - let keyStatus; - if (this.state.keyMatches === false) { - keyStatus = ( -
- {"\uD83D\uDC4E "} - {_t("encryption|access_secret_storage_dialog|security_phrase_incorrect_error")} -
- ); - } else { - keyStatus =
; - } - - content = ( -
-

- {_t( - "encryption|access_secret_storage_dialog|enter_phrase_or_key_prompt", - {}, - { - button: (s) => ( - - {s} - - ), - }, - )} -

- -
- + +
+ - {keyStatus} - - -
- ); - } else { - title = _t("encryption|access_secret_storage_dialog|security_key_title"); - titleClass = ["mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_secureBackupTitle"]; - - const feedbackClasses = classNames({ - "mx_AccessSecretStorageDialog_recoveryKeyFeedback": true, - "mx_AccessSecretStorageDialog_recoveryKeyFeedback--valid": this.state.recoveryKeyCorrect === true, - "mx_AccessSecretStorageDialog_recoveryKeyFeedback--invalid": this.state.recoveryKeyCorrect === false, - }); - const recoveryKeyFeedback =
{this.getKeyValidationText()}
; - - content = ( -
-

{_t("encryption|access_secret_storage_dialog|use_security_key_prompt")}

- -
-
-
- -
- - {_t("encryption|access_secret_storage_dialog|separator", { - recoveryFile: "", - securityKey: "", - })} - -
- - - {_t("action|upload")} - -
-
- {recoveryKeyFeedback} - - -
- ); - } +
+ {recoveryKeyFeedback} + + + + + +
+ ); + // We wrap the content in `BaseDialog` mostly so that we get a `FocusLock` container; otherwise, if the + // SettingsDialog is open, then the `FocusLock` in *that* stops us getting the focus. return ( - -
{content}
+ + + {content} + ); } diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index a545b15337a..b85c1de29ea 100644 --- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -53,7 +53,7 @@ import { getKeyBindingsManager } from "../../../../KeyBindingsManager"; import { _t } from "../../../../languageHandler"; import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import { PosthogAnalytics } from "../../../../PosthogAnalytics"; -import { getCachedRoomIDForAlias } from "../../../../RoomAliasCache"; +import { getCachedRoomIdForAlias } from "../../../../RoomAliasCache"; import { showStartChatInviteDialog } from "../../../../RoomInvite"; import { SettingLevel } from "../../../../settings/SettingLevel"; import SettingsStore from "../../../../settings/SettingsStore"; @@ -608,6 +608,21 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n {filterToLabel(Filter.People)} )} + {filter === null && ( + + )}
); @@ -897,7 +912,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n if ( trimmedQuery.startsWith("#") && trimmedQuery.includes(":") && - (!getCachedRoomIDForAlias(trimmedQuery) || !cli.getRoom(getCachedRoomIDForAlias(trimmedQuery))) + (!getCachedRoomIdForAlias(trimmedQuery) || !cli.getRoom(getCachedRoomIdForAlias(trimmedQuery)!.roomId)) ) { joinRoomSection = (
@@ -997,28 +1012,6 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n ); } - let messageSearchSection: JSX.Element | undefined; - if (filter === null) { - messageSearchSection = ( -
-

- {_t("spotlight_dialog|message_search_section_title")} -

-
- {_t( - "spotlight_dialog|search_messages_hint", - {}, - { icon: () =>
}, - )} -
-
- ); - } - content = ( <> {peopleSection} @@ -1031,7 +1024,6 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n {hiddenResultsSection} {otherSearchesSection} {groupChatSection} - {messageSearchSection} ); } else { diff --git a/src/components/views/directory/NetworkDropdown.tsx b/src/components/views/directory/NetworkDropdown.tsx index 82714915ccf..e46b89578a3 100644 --- a/src/components/views/directory/NetworkDropdown.tsx +++ b/src/components/views/directory/NetworkDropdown.tsx @@ -222,10 +222,10 @@ export const NetworkDropdown: React.FC = ({ protocols, config, setConfig const [ok, newServer] = await finished; if (!ok) return; - if (!allServers.includes(newServer)) { - setUserDefinedServers([...userDefinedServers, newServer]); + if (!allServers.includes(newServer!)) { + setUserDefinedServers([...userDefinedServers, newServer!]); setConfig({ - roomServer: newServer, + roomServer: newServer!, }); } }} diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index cfb95abcc32..027f25cc3a5 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -62,6 +62,20 @@ import { parseUrl } from "../../../utils/UrlUtils"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore.ts"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases.ts"; +// Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin +// because that would allow the iframe to programmatically remove the sandbox attribute, but +// this would only be for content hosted on the same origin as the element client: anything +// hosted on the same origin as the client will get the same access as if you clicked +// a link to it. +const sandboxFlags = + "allow-forms allow-popups allow-popups-to-escape-sandbox " + + "allow-same-origin allow-scripts allow-presentation allow-downloads"; + +// Additional iframe feature permissions +// (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/) +const iframeFeatures = + "microphone; camera; encrypted-media; autoplay; display-capture; clipboard-write; clipboard-read;"; + interface IProps { app: IWidget | IApp; // If room is not specified then it is an account level widget @@ -138,7 +152,7 @@ export default class AppTile extends React.Component { }; private contextMenuButton = createRef(); - private iframe?: HTMLIFrameElement; // ref to the iframe (callback style) + private iframeParent: HTMLElement | null = null; // parent div of the iframe private allowedWidgetsWatchRef?: string; private persistKey: string; private sgWidget?: StopGapWidget; @@ -397,18 +411,47 @@ export default class AppTile extends React.Component { }); } + /** + * Creates the widget iframe and opens communication with the widget. + */ private startMessaging(): void { - try { - this.sgWidget?.startMessaging(this.iframe!); - } catch (e) { - logger.error("Failed to start widget", e); - } + // We create the iframe ourselves rather than leaving the job to React, + // because we need the lifetime of the messaging and the iframe to be + // the same; we don't want strict mode, for instance, to cause the + // messaging to restart (lose its state) without also killing the widget + const iframe = document.createElement("iframe"); + iframe.title = WidgetUtils.getWidgetName(this.props.app); + iframe.allow = iframeFeatures; + iframe.src = this.sgWidget!.embedUrl; + iframe.allowFullscreen = true; + iframe.sandbox = sandboxFlags; + this.iframeParent!.appendChild(iframe); + // In order to start the widget messaging we need iframe.contentWindow + // to exist. Waiting until the next layout gives the browser a chance to + // initialize it. + requestAnimationFrame(() => { + // Handle the race condition (seen in strict mode) where the element + // is added and then removed before we enter this callback + if (iframe.parentElement === null) return; + try { + this.sgWidget?.startMessaging(iframe); + } catch (e) { + logger.error("Failed to start widget", e); + } + }); } - private iframeRefChange = (ref: HTMLIFrameElement): void => { - this.iframe = ref; + /** + * Callback ref for the parent div of the iframe. + */ + private iframeParentRef = (element: HTMLElement | null): void => { if (this.unmounted) return; - if (ref) { + // Detach the existing iframe (if any) from the document so we know not + // to do anything further with it, like starting up the messaging + this.iframeParent?.querySelector("iframe")?.remove(); + this.iframeParent = element; + + if (element && this.sgWidget) { this.startMessaging(); } else { this.resetWidget(this.props); @@ -426,24 +469,8 @@ export default class AppTile extends React.Component { /** * Ends all widget interaction, such as cancelling calls and disabling webcams. - * @private - * @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed. */ - private async endWidgetActions(): Promise { - // widget migration dev note: async to maintain signature - // HACK: This is a really dirty way to ensure that Jitsi cleans up - // its hold on the webcam. Without this, the widget holds a media - // stream open, even after death. See https://github.com/vector-im/element-web/issues/7351 - if (this.iframe) { - // In practice we could just do `+= ''` to trick the browser - // into thinking the URL changed, however I can foresee this - // being optimized out by a browser. Instead, we'll just point - // the iframe at a page that is reasonably safe to use in the - // event the iframe doesn't wink away. - // This is relative to where the Element instance is located. - this.iframe.src = "about:blank"; - } - + private endWidgetActions(): void { if (WidgetType.JITSI.matches(this.props.app.type) && this.props.room) { LegacyCallHandler.instance.hangupCallApp(this.props.room.roomId); } @@ -457,6 +484,7 @@ export default class AppTile extends React.Component { this.sgWidget?.stopMessaging({ forceDestroy: true }); } + private onWidgetReady = (): void => { this.setState({ loading: false }); }; @@ -554,16 +582,11 @@ export default class AppTile extends React.Component { } private reload(): void { - this.endWidgetActions().then(() => { - // reset messaging - this.resetWidget(this.props); - this.startMessaging(); - - if (this.iframe && this.sgWidget) { - // Reload iframe - this.iframe.src = this.sgWidget.embedUrl; - } - }); + this.endWidgetActions(); + // reset messaging + this.resetWidget(this.props); + this.iframeParent?.querySelector("iframe")?.remove(); + this.startMessaging(); } // TODO replace with full screen interactions @@ -621,20 +644,6 @@ export default class AppTile extends React.Component { public render(): React.ReactNode { let appTileBody: JSX.Element | undefined; - // Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin - // because that would allow the iframe to programmatically remove the sandbox attribute, but - // this would only be for content hosted on the same origin as the element client: anything - // hosted on the same origin as the client will get the same access as if you clicked - // a link to it. - const sandboxFlags = - "allow-forms allow-popups allow-popups-to-escape-sandbox " + - "allow-same-origin allow-scripts allow-presentation allow-downloads"; - - // Additional iframe feature permissions - // (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/) - const iframeFeatures = - "microphone; camera; encrypted-media; autoplay; display-capture; clipboard-write; " + "clipboard-read;"; - const appTileBodyClass = classNames({ "mx_AppTileBody": true, "mx_AppTileBody--large": !this.props.miniMode, @@ -654,8 +663,6 @@ export default class AppTile extends React.Component {
); - const widgetTitle = WidgetUtils.getWidgetName(this.props.app); - if (this.sgWidget === null) { appTileBody = (
@@ -692,16 +699,8 @@ export default class AppTile extends React.Component { } else if (this.sgWidget) { appTileBody = ( <> -
+
{this.state.loading && loadingElement} -