diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4d173ab222..523da568d9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -21,9 +21,15 @@ /apps/web/src/models/Call.ts @element-hq/element-call-reviewers +/apps/web/test/unit-tests/models/Call-test.ts @element-hq/element-call-reviewers +/apps/web/src/stores/CallStore.ts @element-hq/element-call-reviewers +/apps/web/test/unit-tests/stores/CallStore-test.ts @element-hq/element-call-reviewers /apps/web/src/call-types.ts @element-hq/element-call-reviewers /apps/web/src/components/views/voip @element-hq/element-call-reviewers -/apps/web/playwright/e2e/voip/element-call.spec.ts @element-hq/element-call-reviewers +/apps/web/res/css/views/voip @element-hq/element-call-reviewers +/apps/web/playwright/e2e/voip @element-hq/element-call-reviewers +/apps/web/playwright/snapshots/voip @element-hq/element-call-reviewers +/apps/web/test/test-utils/call.ts @element-hq/element-call-reviewers # Ignore translations as those will be updated by GHA for Localazy download /apps/web/src/i18n/strings diff --git a/.github/actions/setup-playwright/action.yml b/.github/actions/setup-playwright/action.yml index 1276191d2b..d711672106 100644 --- a/.github/actions/setup-playwright/action.yml +++ b/.github/actions/setup-playwright/action.yml @@ -21,7 +21,7 @@ runs: PREFIX: ${{ runner.os }}-${{ runner.arch }} - name: Cache playwright binaries - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 if: inputs.write-cache == 'true' id: cache with: @@ -30,7 +30,7 @@ runs: # When running in merge queue only restore the cache, never write it - name: Restore playwright binaries cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 if: inputs.write-cache != 'true' id: cache-restore with: diff --git a/.github/labels.yml b/.github/labels.yml index 8cf5613f92..cced609c4e 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -91,9 +91,6 @@ - name: "A-Modules" description: "Module system related" color: "bfd4f2" -- name: "A-New-Search-Experience" - description: "The new search dialog available in Labs" - color: "bfd4f2" - name: "A-Packaging" description: "Packaging, signing, releasing" color: "bfd4f2" diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 5045f2bfc9..d0843c8197 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -1,3 +1,5 @@ _extends: matrix-org/matrix-js-sdk version-resolver: default: patch +# Exclude modules releases +tag-prefix: v diff --git a/.github/renovate.json b/.github/renovate.json index 928cfd4ad0..30fc8f8028 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -11,15 +11,25 @@ "matchDepTypes": ["testcontainers-docker"], "matchPackageNames": ["*"] }, + { + "description": "Group all pnpm updates", + "groupName": "pnpm", + "groupSlug": "pnpm", + "matchPackageNames": ["pnpm", "ghcr.io/pnpm/pnpm"] + }, { "description": "Separate updates to overrides from other groups", - "matchDepTypes": ["pnpm.overrides"], + "matchManagers": ["npm"], + "matchFileNames": ["pnpm-workspace.yaml"], + "matchDepTypes": ["pnpm-workspace.overrides"], "groupSlug": null }, { "description": "Disable any major updates to overrides as this almost always is wrong", - "matchDepTypes": ["pnpm.overrides"], "matchUpdateTypes": ["major"], + "matchManagers": ["npm"], + "matchFileNames": ["pnpm-workspace.yaml"], + "matchDepTypes": ["pnpm-workspace.overrides"], "enabled": false } ], @@ -41,6 +51,25 @@ "matchStrings": ["hakDependencies.$each(function($v, $k) { { 'packageName': $k, 'currentValue': $v } })"], "datasourceTemplate": "npm", "depTypeTemplate": "hak" + }, + { + "description": "Update pnpm in devEngines", + "customType": "jsonata", + "fileFormat": "json", + "managerFilePatterns": ["/(^|/)package\\.json$/"], + "matchStrings": [ + "devEngines.packageManager.name = 'pnpm' ? { 'currentValue': devEngines.packageManager.version } : null" + ], + "depNameTemplate": "pnpm", + "datasourceTemplate": "npm", + "depTypeTemplate": "devengines" } - ] + ], + "toolSettings": { + "nodeMaxMemory": 2048 + }, + "env": { + "PNPM_MAX_WORKERS": "2", + "PNPM_WORKERS": "2" + } } diff --git a/.github/workflows/release-module.yml b/.github/workflows/release-module.yml new file mode 100644 index 0000000000..699562dc54 --- /dev/null +++ b/.github/workflows/release-module.yml @@ -0,0 +1,57 @@ +name: Release module +run-name: Release modules + +on: + push: + tags: + - "module/*/v*.*.*" + +permissions: {} + +jobs: + release: + name: Build and release module + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - name: Parse tag + env: + TAG: ${{ github.ref_name }} + run: | + MODULE=$(echo "$TAG" | cut -d'/' -f2) + VERSION=$(echo "$TAG" | cut -d'/' -f3) + echo "MODULE=$MODULE" >> "$GITHUB_ENV" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "TAG=$TAG" >> "$GITHUB_ENV" + echo "ARCHIVE=${MODULE}-${VERSION}.zip" >> "$GITHUB_ENV" + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + cache: "pnpm" + node-version: "lts/*" + + - name: Install deps + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm --filter "./modules/$MODULE" run build + + - name: Create archive + run: zip -r "$ARCHIVE" -j "modules/$MODULE/lib" + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$TAG" \ + --title "$VERSION $MODULE" \ + --draft \ + --latest=false \ + "$ARCHIVE" diff --git a/.github/workflows/shared-component-storybook-netlify.yaml b/.github/workflows/shared-component-storybook-netlify.yaml new file mode 100644 index 0000000000..ae0e2c1550 --- /dev/null +++ b/.github/workflows/shared-component-storybook-netlify.yaml @@ -0,0 +1,47 @@ +# Triggers after the shared component storybook has finished building, +# taking the artifact and uploading it to Netlify for easier viewing +name: Upload Shared Component Storybook to Netlify +on: + # Privilege escalation necessary to deploy to Netlify + # 🚨 We must not execute any checked out code here. + workflow_run: # zizmor: ignore[dangerous-triggers] + workflows: ["Build shared component storybook"] + types: + - completed + +concurrency: + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }} + cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }} + +permissions: {} + +jobs: + report: + if: github.event.workflow_run.conclusion != 'cancelled' && github.event.workflow_run.event == 'pull_request' + name: Upload Storybook + runs-on: ubuntu-24.04 + environment: Netlify + permissions: + actions: read + deployments: write + steps: + - name: 📥 Download artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + name: shared-components-storybook + path: storybook-static + + - name: 📤 Deploy to Netlify + uses: matrix-org/netlify-pr-preview@9805cd123fc9a7e421e35340a05e1ebc5dee46b5 # v3 + with: + path: storybook-static + owner: ${{ github.event.workflow_run.head_repository.owner.login }} + branch: ${{ github.event.workflow_run.head_branch }} + revision: ${{ github.event.workflow_run.head_sha }} + token: ${{ secrets.NETLIFY_AUTH_TOKEN }} + site_id: ${{ vars.NETLIFY_SITE_ID }} + desc: Shared Component Storybook + deployment_env: SharedComponentStorybook + prefix: "storybook-" diff --git a/.gitignore b/.gitignore index 74cd2f75e5..49264df068 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ package-lock.json /.tmp .vscode .vscode/ +.zed/ .env .env.* coverage diff --git a/.husky/pre-commit b/.husky/pre-commit index 3c15019b78..8ba25f36cd 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -npx lint-staged --concurrent false +pnpm exec lint-staged --concurrent false diff --git a/.lintstagedrc b/.lintstagedrc index f8904d4eb3..1f90222067 100644 --- a/.lintstagedrc +++ b/.lintstagedrc @@ -1,3 +1,4 @@ { - "*": "prettier --write --ignore-unknown" + "*": "oxfmt --no-error-on-unmatched-pattern", + "*.{js,jsx,ts,tsx,mjs,cjs}": "oxlint --no-error-on-unmatched-pattern" } diff --git a/.oxfmtrc.jsonc b/.oxfmtrc.jsonc new file mode 100644 index 0000000000..04f20cdda4 --- /dev/null +++ b/.oxfmtrc.jsonc @@ -0,0 +1,73 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 120, + "tabWidth": 4, + "quoteProps": "consistent", + "trailingComma": "all", + "sortPackageJson": false, + "ignorePatterns": [ + "/build", + "/dist", + "/lib", + "node_modules", + "/apps/web/webapp", + "/*.log", + "pnpm-lock.yaml", + "electron/dist", + "electron/pub", + "**/.idea", + "/.tmp", + "webpack-stats.json", + ".vscode", + ".vscode/", + ".env", + "coverage", + // Auto-generated files, + "*.api.md", + "/apps/web/src/modules.ts", + "/apps/web/src/modules.js", + "src/i18n/strings", + "/apps/web/build_config.yaml", + // Raises an error because it contains a template var breaking the script tag, + "/apps/web/src/vector/index.html", + "/apps/web/src/vector/modernizr.cjs", + "/docs/lib", + "/book", + "debian/tmp", + "/.npmrc", + "package-lock.json", + // This file is generated + "CHANGELOG.md", + "/docs/changelogs", + // Legacy skinning file that some people might still have, + "/apps/web/src/component-index.js", + // Downloaded and already minified, + "/apps/web/res/jitsi_external_api.min.js", + // This file is also machine-generated, + "/apps/web/playwright/e2e/crypto/test_indexeddb_cryptostore_dump/dump.json", + "/apps/web/playwright/test-results/", + "/apps/web/playwright/html-report/", + "/apps/web/playwright/logs/", + "/apps/web/playwright/snapshots/", + "/apps/desktop/.hak/", + "/apps/desktop/dist/", + "/apps/desktop/build/", + "/apps/desktop/dockerbuild/", + "/apps/desktop/deploys/", + "/apps/desktop/lib/", + "/apps/desktop/webapp", + "/apps/desktop/playwright/html-report", + "/apps/desktop/playwright/test-results", + // Shared components generated files, + "/packages/shared-components/dist/", + "/packages/shared-components/src/i18n/i18nKeys.d.ts", + "/packages/shared-components/typedoc/", + "/packages/shared-components/storybook-static/", + // These files are generated by running `pnpm -r lint:types` and do not adhere to oxfmt's requirements. + // All of them are .gitignored within their parent directory., + "/packages/playwright-common/lib/", + "/packages/module-api/lib/", + "/packages/module-api/temp/", + "/.nx/", + ], +} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 0a8f1a2baf..0000000000 --- a/.prettierignore +++ /dev/null @@ -1,71 +0,0 @@ -/build -/dist -/lib -node_modules -/apps/web/webapp -/*.log -pnpm-lock.yaml -electron/dist -electron/pub -**/.idea -/.tmp -webpack-stats.json -.vscode -.vscode/ -.env -coverage -# Auto-generated files -*.api.md -/apps/web/src/modules.ts -/apps/web/src/modules.js -src/i18n/strings -/apps/web/build_config.yaml -# Raises an error because it contains a template var breaking the script tag -/apps/web/src/vector/index.html -/apps/web/src/vector/modernizr.cjs -/docs/lib -/book -debian/tmp -/.npmrc -package-lock.json - -# This file is owned, parsed, and generated by allchange, which doesn't comply with prettier -CHANGELOG.md -/docs/changelogs - -# Legacy skinning file that some people might still have -/apps/web/src/component-index.js - -# Downloaded and already minified -/apps/web/res/jitsi_external_api.min.js - -# This file is also machine-generated -/apps/web/playwright/e2e/crypto/test_indexeddb_cryptostore_dump/dump.json -/apps/web/playwright/test-results/ -/apps/web/playwright/html-report/ -/apps/web/playwright/logs/ -/apps/web/playwright/snapshots/ - -/apps/desktop/.hak/ -/apps/desktop/dist/ -/apps/desktop/build/ -/apps/desktop/dockerbuild/ -/apps/desktop/deploys/ -/apps/desktop/lib/ -/apps/desktop/webapp -/apps/desktop/playwright/html-report -/apps/desktop/playwright/test-results - -# Shared components generated files -/packages/shared-components/dist/ -/packages/shared-components/src/i18n/i18nKeys.d.ts -/packages/shared-components/typedoc/ -/packages/shared-components/storybook-static/ - -# These files are generated by running `pnpm -r lint:types` and do not adhere to prettier's requirements. -# All of them are .gitignored within their parent directory. -/packages/playwright-common/lib/ -/packages/module-api/lib/ -/packages/module-api/temp/ - -/.nx/ diff --git a/.prettierrc.cjs b/.prettierrc.cjs deleted file mode 100644 index 6a17910f1a..0000000000 --- a/.prettierrc.cjs +++ /dev/null @@ -1 +0,0 @@ -module.exports = require("eslint-plugin-matrix-org/.prettierrc.js"); diff --git a/CHANGELOG.md b/CHANGELOG.md index a721f263bd..d9d60ead3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,276 @@ +Changes in [1.12.26](https://github.com/element-hq/element-web/releases/tag/v1.12.26) (2026-08-18) +================================================================================================== +## ✨ Features + +* Timeline MVVM 1 - Shared TimelineView and Overlay Buttons ([#34464](https://github.com/element-hq/element-web/pull/34464)). Contributed by @langleyd. +* Tell the user when registration is being rate limited ([#34519](https://github.com/element-hq/element-web/pull/34519)). Contributed by @hayaksi1. +* Provide element-web modules docker image ([#34657](https://github.com/element-hq/element-web/pull/34657)). Contributed by @t3chguy. +* Show \& clear your own on-a-call status ([#34613](https://github.com/element-hq/element-web/pull/34613)). Contributed by @dbkr. +* feat(widget): Rtc transports discovery for widgets ([#34393](https://github.com/element-hq/element-web/pull/34393)). Contributed by @BillCarsonFr. +* Custom user status ([#34386](https://github.com/element-hq/element-web/pull/34386)). Contributed by @dbkr. +* Add a Module API for accessing storage helper functions. ([#34284](https://github.com/element-hq/element-web/pull/34284)). Contributed by @Half-Shot. +* Room list: persist section state (expanded/collapsed) ([#34351](https://github.com/element-hq/element-web/pull/34351)). Contributed by @florianduros. + +## 🐛 Bug Fixes + +* [Backport staging] macOS Fix: Can't change homeserver on login or press the logout button on the splash/loading srceen ([#34706](https://github.com/element-hq/element-web/pull/34706)). Contributed by @RiotRobot. +* [Backport staging] Cachebust languages.json ([#34711](https://github.com/element-hq/element-web/pull/34711)). Contributed by @RiotRobot. +* Fix layout of user status dropdown ([#34668](https://github.com/element-hq/element-web/pull/34668)). Contributed by @dbkr. +* Fix notification badge sizing ([#34655](https://github.com/element-hq/element-web/pull/34655)). Contributed by @ZacksBot. +* Keep a file's extension when it is renamed in the desktop save dialog ([#34601](https://github.com/element-hq/element-web/pull/34601)). Contributed by @hayaksi1. +* Update the pinned message banner when a pinned message is edited ([#34631](https://github.com/element-hq/element-web/pull/34631)). Contributed by @hayaksi1. +* Use the dark code highlighting stylesheet in the dark-custom theme ([#34491](https://github.com/element-hq/element-web/pull/34491)). Contributed by @hayaksi1. +* Keep a copy of the audio buffer so the WAV fallback can run ([#34481](https://github.com/element-hq/element-web/pull/34481)). Contributed by @hayaksi1. +* Update the member list invite button when power levels change ([#34475](https://github.com/element-hq/element-web/pull/34475)). Contributed by @hayaksi1. +* Open linkified room aliases and permalinks in the app rather than the browser ([#34507](https://github.com/element-hq/element-web/pull/34507)). Contributed by @hayaksi1. +* Stop large audio playback when the media element ends ([#34495](https://github.com/element-hq/element-web/pull/34495)). Contributed by @hayaksi1. +* Fix spacing in user status dropdown ([#34589](https://github.com/element-hq/element-web/pull/34589)). Contributed by @dbkr. +* Reverse a confirmed autocomplete with a single undo ([#34635](https://github.com/element-hq/element-web/pull/34635)). Contributed by @hayaksi1. +* Allow a notification keyword to start with a dot ([#34574](https://github.com/element-hq/element-web/pull/34574)). Contributed by @hayaksi1. +* Fix mistaken reference to element\_call.disable in docs ([#34647](https://github.com/element-hq/element-web/pull/34647)). Contributed by @robintown. +* Round the room preview dialog like everything around it ([#34597](https://github.com/element-hq/element-web/pull/34597)). Contributed by @hayaksi1. +* Increase Threads panel header height to 64px to match Pinned Message banner ([#34566](https://github.com/element-hq/element-web/pull/34566)). Contributed by @PrinceXDev. +* fix: align room timeline scrollbar to viewport edge ([#33788](https://github.com/element-hq/element-web/pull/33788)). Contributed by @Adi-Beker. +* Let a click on the separator wander a little before it counts as a drag ([#34580](https://github.com/element-hq/element-web/pull/34580)). Contributed by @hayaksi1. +* Surface an error when a downloaded file can't be opened instead of failing silently ([#33998](https://github.com/element-hq/element-web/pull/33998)). Contributed by @hayaksi1. +* Keep emoji at code size inside code blocks so line numbers stay aligned ([#34497](https://github.com/element-hq/element-web/pull/34497)). Contributed by @hayaksi1. +* Make permalinks to the same event work more than once ([#34483](https://github.com/element-hq/element-web/pull/34483)). Contributed by @hayaksi1. +* Remove code to round panel size on resize ([#34543](https://github.com/element-hq/element-web/pull/34543)). Contributed by @MidhunSureshR. +* Do not ask for a room address when the room already has one ([#34499](https://github.com/element-hq/element-web/pull/34499)). Contributed by @hayaksi1. +* Show the filename in the audio player title when the event has one ([#34479](https://github.com/element-hq/element-web/pull/34479)). Contributed by @hayaksi1. +* Treat an unset profile field as unchanged in collapsed membership summaries ([#34493](https://github.com/element-hq/element-web/pull/34493)). Contributed by @hayaksi1. +* Open space settings when editing a space topic ([#34477](https://github.com/element-hq/element-web/pull/34477)). Contributed by @hayaksi1. +* Show "border" type of separator when clicking on separator ([#34549](https://github.com/element-hq/element-web/pull/34549)). Contributed by @MidhunSureshR. +* fix member list scroll bug where invited 3PID users replace joined members ([#34427](https://github.com/element-hq/element-web/pull/34427)). Contributed by @rizzler13. +* Update Twemoji credits links ([#34461](https://github.com/element-hq/element-web/pull/34461)). Contributed by @t3chguy. +* Fix joinRoom failing when the roomviewstore state changes. ([#34180](https://github.com/element-hq/element-web/pull/34180)). Contributed by @Half-Shot. +* Fixed collapsed URL preview incorrect aspect ratio for chromium ([#34382](https://github.com/element-hq/element-web/pull/34382)). Contributed by @Siriusmart. + + +Changes in [1.12.25](https://github.com/element-hq/element-web/releases/tag/v1.12.25) (2026-08-05) +================================================================================================== +## 🦖 Deprecations + +* Remove support for MSC3391 \& MSC3852 ([#34400](https://github.com/element-hq/element-web/pull/34400)). Contributed by @t3chguy. + +## ✨ Features + +* Auto Collapse Behaviour - Collapse left panel during calls ([#33771](https://github.com/element-hq/element-web/pull/33771)). Contributed by @MidhunSureshR. +* Use correct color for cancel button in dialog ([#34308](https://github.com/element-hq/element-web/pull/34308)). Contributed by @florianduros. +* Auto Collapse Behaviour - Collapse left panel on window resize ([#32964](https://github.com/element-hq/element-web/pull/32964)). Contributed by @MidhunSureshR. +* Room list: change `Edit section` label to `Save` in edit section dialog ([#34364](https://github.com/element-hq/element-web/pull/34364)). Contributed by @florianduros. +* Set auto on-a-call status ([#34306](https://github.com/element-hq/element-web/pull/34306)). Contributed by @dbkr. +* Collapsed URL previews in timeline ([#34165](https://github.com/element-hq/element-web/pull/34165)). Contributed by @Siriusmart. +* Include the url preview bundle field in the devtools timeline event editor ([#34289](https://github.com/element-hq/element-web/pull/34289)). Contributed by @Siriusmart. +* Use url preview bundle for URL preview in timeline (MSC4095) ([#34170](https://github.com/element-hq/element-web/pull/34170)). Contributed by @Siriusmart. +* Support for reading m.call status ([#34295](https://github.com/element-hq/element-web/pull/34295)). Contributed by @dbkr. +* Auto Collapse Behaviour - Add necessary functionality to `UIStore` ([#32963](https://github.com/element-hq/element-web/pull/32963)). Contributed by @MidhunSureshR. +* Introduce a Module API to get application settings. ([#34278](https://github.com/element-hq/element-web/pull/34278)). Contributed by @Half-Shot. +* Make notification settings copy/layout more user-friendly ([#34120](https://github.com/element-hq/element-web/pull/34120)). Contributed by @mxandreas. + +## 🐛 Bug Fixes + +* Fix various dialogs failing to open due to containing linked text ([#34304](https://github.com/element-hq/element-web/pull/34304)). Contributed by @Half-Shot. +* Draw a styled 32px macOS title bar band ([#34419](https://github.com/element-hq/element-web/pull/34419)). Contributed by @langleyd. +* Room list: fix scrolling with touch device ([#34381](https://github.com/element-hq/element-web/pull/34381)). Contributed by @florianduros. +* Update to Seshat 5.0.0, fixing panics ([#34396](https://github.com/element-hq/element-web/pull/34396)). Contributed by @richvdh. +* Add ellipsis and title to spotlight search results ([#34379](https://github.com/element-hq/element-web/pull/34379)). Contributed by @langleyd. +* Room list: increase startup performance when rooms have notifications ([#34358](https://github.com/element-hq/element-web/pull/34358)). Contributed by @florianduros. +* Widen the macOS title-bar drag strips so the window is easy to move ([#33991](https://github.com/element-hq/element-web/pull/33991)). Contributed by @hayaksi1. +* Warn when an encrypted search runs before the index has finished building ([#34001](https://github.com/element-hq/element-web/pull/34001)). Contributed by @hayaksi1. +* Make the text copy button use IconButton ([#34224](https://github.com/element-hq/element-web/pull/34224)). Contributed by @dbkr. +* Room list: fix \*Chat moved\* toast appearing when room list is loaded ([#34305](https://github.com/element-hq/element-web/pull/34305)). Contributed by @florianduros. +* Enable some oxlint a11y rules \& improve keyboard accessibility ([#34291](https://github.com/element-hq/element-web/pull/34291)). Contributed by @t3chguy. +* Fix join call button in header having two labels ([#34293](https://github.com/element-hq/element-web/pull/34293)). Contributed by @robintown. +* Fixed sending url preview bundles relying on update delay ([#34266](https://github.com/element-hq/element-web/pull/34266)). Contributed by @Siriusmart. +* Room list: fix when a room is added twice ([#34281](https://github.com/element-hq/element-web/pull/34281)). Contributed by @florianduros. +* Make the persistent-storage request observable and warn when it is denied ([#33987](https://github.com/element-hq/element-web/pull/33987)). Contributed by @hayaksi1. +* Give the collapsed space panel separator clearance from the macOS traffic lights ([#34243](https://github.com/element-hq/element-web/pull/34243)). Contributed by @spoisseroux. +* Fix incorrect plural form in call tile ([#34272](https://github.com/element-hq/element-web/pull/34272)). Contributed by @florianduros. + + +Changes in [1.12.24](https://github.com/element-hq/element-web/releases/tag/v1.12.24) (2026-07-21) +================================================================================================== +## ✨ Features + +* Add user status to autocomplete suggestion ([#34241](https://github.com/element-hq/element-web/pull/34241)). Contributed by @dbkr. +* Add user status to dm room header ([#34225](https://github.com/element-hq/element-web/pull/34225)). Contributed by @dbkr. +* User status in room summary card ([#34239](https://github.com/element-hq/element-web/pull/34239)). Contributed by @dbkr. +* Sending URL Preview Bundles (MSC 4095) ([#34150](https://github.com/element-hq/element-web/pull/34150)). Contributed by @Siriusmart. +* Add config param to disable client wellknown fetch ([#34084](https://github.com/element-hq/element-web/pull/34084)). Contributed by @langleyd. +* Ongoing call tiles - Implement the view-models and store ([#34198](https://github.com/element-hq/element-web/pull/34198)). Contributed by @MidhunSureshR. +* Ongoing call tiles - Implement the required views ([#34197](https://github.com/element-hq/element-web/pull/34197)). Contributed by @MidhunSureshR. +* Add user status emoji to member list ([#34210](https://github.com/element-hq/element-web/pull/34210)). Contributed by @dbkr. +* Add user status in user info right panel card ([#34212](https://github.com/element-hq/element-web/pull/34212)). Contributed by @dbkr. +* RoomList: add back Favourites and Low Priority filters when sections are disabled ([#34162](https://github.com/element-hq/element-web/pull/34162)). Contributed by @florianduros. +* Add user status to DM rooms in the room list ([#34191](https://github.com/element-hq/element-web/pull/34191)). Contributed by @dbkr. +* Adapt OAuth2 implementation to Matrix Spec v1.18 ([#34026](https://github.com/element-hq/element-web/pull/34026)). Contributed by @t3chguy. +* Implement tombstone tiles for calls in room and DM ([#34141](https://github.com/element-hq/element-web/pull/34141)). Contributed by @MidhunSureshR. +* Allow disabling legacy calls and make `Voice & Video` settings `Legacy Voice & Video` ([#33692](https://github.com/element-hq/element-web/pull/33692)). Contributed by @toger5. +* UI for setting user status ([#33856](https://github.com/element-hq/element-web/pull/33856)). Contributed by @dbkr. +* Start voice call in PiP (not in fullscreen) ([#34055](https://github.com/element-hq/element-web/pull/34055)). Contributed by @toger5. +* Add a module API for overriding the composer preview. ([#33978](https://github.com/element-hq/element-web/pull/33978)). Contributed by @Half-Shot. +* Remove legacy room list ([#34040](https://github.com/element-hq/element-web/pull/34040)). Contributed by @florianduros. +* Add URL preview above message composer ([#33964](https://github.com/element-hq/element-web/pull/33964)). Contributed by @Half-Shot. + +## 🐛 Bug Fixes + +* Fix icon in Jitsi lobby not rendering correctly ([#34246](https://github.com/element-hq/element-web/pull/34246)). Contributed by @t3chguy. +* Automatically recover from a renderer crash instead of leaving a blank window ([#33988](https://github.com/element-hq/element-web/pull/33988)). Contributed by @hayaksi1. +* Fetch authenticated media through the session for "Save image as" ([#33997](https://github.com/element-hq/element-web/pull/33997)). Contributed by @hayaksi1. +* RoomList: fix room scroll when clicked in thread activity centre ([#34179](https://github.com/element-hq/element-web/pull/34179)). Contributed by @florianduros. +* Center long user ids in the user menu ([#34182](https://github.com/element-hq/element-web/pull/34182)). Contributed by @dbkr. +* Fix composer preview getting stuck on a single module API preview ([#34173](https://github.com/element-hq/element-web/pull/34173)). Contributed by @Half-Shot. +* Throttle notification sounds so a backlog doesn't play them all at once ([#33989](https://github.com/element-hq/element-web/pull/33989)). Contributed by @hayaksi1. +* Change user ID colour to secondary ([#34129](https://github.com/element-hq/element-web/pull/34129)). Contributed by @dbkr. +* Fix map tooltip overlapping message composer ([#33982](https://github.com/element-hq/element-web/pull/33982)). Contributed by @Tamajit-005. +* Clear the composer preview after sending a message. ([#34116](https://github.com/element-hq/element-web/pull/34116)). Contributed by @Half-Shot. +* Room list: fix unnecessary re-rendering of room list items while scrolling ([#34112](https://github.com/element-hq/element-web/pull/34112)). Contributed by @florianduros. +* Room list: improve performance with smarter custom section loading ([#34102](https://github.com/element-hq/element-web/pull/34102)). Contributed by @florianduros. +* Pluralise the multi-session remove button and confirmation dialog ([#33983](https://github.com/element-hq/element-web/pull/33983)). Contributed by @nnhhoang. + + +Changes in [1.12.23](https://github.com/element-hq/element-web/releases/tag/v1.12.23) (2026-07-07) +================================================================================================== +## ✨ Features + +* Sticky Header for Room List Sections ([#33968](https://github.com/element-hq/element-web/pull/33968)). Contributed by @langleyd. +* Improve link preview look and feel ([#33981](https://github.com/element-hq/element-web/pull/33981)). Contributed by @Half-Shot. +* Room list: move sections out of labs to all the users ([#33810](https://github.com/element-hq/element-web/pull/33810)). Contributed by @florianduros. +* Add "user identity" display to dev tools ([#33977](https://github.com/element-hq/element-web/pull/33977)). Contributed by @richvdh. +* Add unread toast to room list sections ([#33961](https://github.com/element-hq/element-web/pull/33961)). Contributed by @langleyd. +* Room list: add drag and drop of sections to reorder them ([#33606](https://github.com/element-hq/element-web/pull/33606)). Contributed by @florianduros. +* Room list: add release announcement for sections ([#33800](https://github.com/element-hq/element-web/pull/33800)). Contributed by @florianduros. + +## 🐛 Bug Fixes + +* [Backport staging] Room list: put toast over sticky headers ([#34137](https://github.com/element-hq/element-web/pull/34137)). Contributed by @RiotRobot. +* [Backport staging] Update Compound to fix tooltips ([#34097](https://github.com/element-hq/element-web/pull/34097)). Contributed by @RiotRobot. +* Fix: Focusing a room in the room list(without hovering) doesn't allow tabbing to the more menu ([#34043](https://github.com/element-hq/element-web/pull/34043)). Contributed by @langleyd. +* Fix double tooltip on collapsed Quick Settings button ([#33923](https://github.com/element-hq/element-web/pull/33923)). Contributed by @t3chguy. +* Fix long display / user names in UserMenu ([#33900](https://github.com/element-hq/element-web/pull/33900)). Contributed by @dbkr. +* Fix handling of deeplinks on Element Desktop ([#33827](https://github.com/element-hq/element-web/pull/33827)). Contributed by @t3chguy. + + +Changes in [1.12.22](https://github.com/element-hq/element-web/releases/tag/v1.12.22) (2026-06-23) +================================================================================================== +## ✨ Features + +* User status in user menu ([#33797](https://github.com/element-hq/element-web/pull/33797)). Contributed by @dbkr. +* Room list: add notifications to section headers ([#33826](https://github.com/element-hq/element-web/pull/33826)). Contributed by @florianduros. +* Room list: remove logic to expand a section when a filter is selected ([#33785](https://github.com/element-hq/element-web/pull/33785)). Contributed by @florianduros. +* Add mechanism to locally enforce MSC1763 retention rules ([#33772](https://github.com/element-hq/element-web/pull/33772)). Contributed by @Half-Shot. +* Disable URL previews per-message when the message provides a hint ([#33775](https://github.com/element-hq/element-web/pull/33775)). Contributed by @Half-Shot. +* Room list: improve section in room list context menu ([#33733](https://github.com/element-hq/element-web/pull/33733)). Contributed by @florianduros. +* Room list: remove "Sections are only for you” in edition section dialog ([#33780](https://github.com/element-hq/element-web/pull/33780)). Contributed by @florianduros. +* Room list: remove checkmark in section toast ([#33779](https://github.com/element-hq/element-web/pull/33779)). Contributed by @florianduros. +* Room list: add fade effect to room list item being dragged ([#33696](https://github.com/element-hq/element-web/pull/33696)). Contributed by @florianduros. +* Add user status on user profile icon ([#33653](https://github.com/element-hq/element-web/pull/33653)). Contributed by @dbkr. +* [Labs] Sign in with QR on new EW using generated QR for MSC4108 v2024 ([#33184](https://github.com/element-hq/element-web/pull/33184)). Contributed by @t3chguy. +* Room list: add expand all icon to room list header ([#33732](https://github.com/element-hq/element-web/pull/33732)). Contributed by @florianduros. +* Implement new separator design ([#33599](https://github.com/element-hq/element-web/pull/33599)). Contributed by @MidhunSureshR. + +## 🐛 Bug Fixes + +* [Backport staging] Fix broken scrollbar introduced by separator redesign ([#33947](https://github.com/element-hq/element-web/pull/33947)). Contributed by @RiotRobot. +* Apply html utils sanitiser to embedded page ([#33842](https://github.com/element-hq/element-web/pull/33842)). Contributed by @t3chguy. +* Room list: fix keyboard navigation on sections ([#33809](https://github.com/element-hq/element-web/pull/33809)). Contributed by @florianduros. +* Room list: hide the empty/collapse icon when the room list is empty ([#33814](https://github.com/element-hq/element-web/pull/33814)). Contributed by @florianduros. +* Handle unknown screens better ([#33793](https://github.com/element-hq/element-web/pull/33793)). Contributed by @t3chguy. +* Make presence icons \& colours consistent throughout the app ([#33764](https://github.com/element-hq/element-web/pull/33764)). Contributed by @dbkr. +* Add padding to account for input outline in devtools ([#33766](https://github.com/element-hq/element-web/pull/33766)). Contributed by @Johennes. +* Limit width of the display name in user menu ([#33746](https://github.com/element-hq/element-web/pull/33746)). Contributed by @dbkr. +* Room list: hide empty section when a filter is enabled ([#33747](https://github.com/element-hq/element-web/pull/33747)). Contributed by @florianduros. +* Room list: display compose menu when sections are enabled ([#33725](https://github.com/element-hq/element-web/pull/33725)). Contributed by @florianduros. + + +Changes in [1.12.21](https://github.com/element-hq/element-web/releases/tag/v1.12.21) (2026-06-09) +================================================================================================== +## ✨ Features + +* Bump module API to 1.14.0 ([#33685](https://github.com/element-hq/element-web/pull/33685)). Contributed by @Half-Shot. +* Apply new design and display logic to logout confirmation dialog ([#33426](https://github.com/element-hq/element-web/pull/33426)). Contributed by @uhoreg. +* Room list: improve custom sections in Spaces ([#33523](https://github.com/element-hq/element-web/pull/33523)). Contributed by @florianduros. +* Periodically nag the user if their device remains unverified ([#33346](https://github.com/element-hq/element-web/pull/33346)). Contributed by @uhoreg. +* Use the separator as border between roomlist and main panel ([#33598](https://github.com/element-hq/element-web/pull/33598)). Contributed by @MidhunSureshR. +* Add support for `m.recent_emoji` account data event ([#33172](https://github.com/element-hq/element-web/pull/33172)). Contributed by @t3chguy. +* Room list: reduce font size of sections ([#33580](https://github.com/element-hq/element-web/pull/33580)). Contributed by @florianduros. + +## 🐛 Bug Fixes + +* Remove resizer from fullscreen modules(like multiroom) ([#33684](https://github.com/element-hq/element-web/pull/33684)). Contributed by @langleyd. +* fix: use configured brand name in JSON and PlainText chat export filenames ([#33680](https://github.com/element-hq/element-web/pull/33680)). Contributed by @RoySerbi. +* Fix pinned message banner disappearing when a pinned message event is unkown ([#33534](https://github.com/element-hq/element-web/pull/33534)). Contributed by @florianduros. + + +Changes in [1.12.20](https://github.com/element-hq/element-web/releases/tag/v1.12.20) (2026-05-27) +================================================================================================== +## 🐛 Bug Fixes + +* [Backport staging] Realign the User Menu profile picture on desktop ([#33634](https://github.com/element-hq/element-web/pull/33634)). Contributed by @RiotRobot. + + +Changes in [1.12.19](https://github.com/element-hq/element-web/releases/tag/v1.12.19) (2026-05-27) +================================================================================================== +## 🦖 Deprecations + +* Remove MSC3215 (Report to Moderators) labs feature ([#33492](https://github.com/element-hq/element-web/pull/33492)). Contributed by @turt2live. + +## ✨ Features + +* Tweak new user menu design ([#33444](https://github.com/element-hq/element-web/pull/33444)). Contributed by @dbkr. +* Call Tile - Improve tile alignment in modern and bubble layout ([#33478](https://github.com/element-hq/element-web/pull/33478)). Contributed by @MidhunSureshR. +* Module API for adding new file upload mechanisms ([#33355](https://github.com/element-hq/element-web/pull/33355)). Contributed by @Half-Shot. +* Incoming Element Calls now trigger a regular OS notification ([#33499](https://github.com/element-hq/element-web/pull/33499)). Contributed by @MatrimAl. +* Call Tile - Support declined call tile ([#33371](https://github.com/element-hq/element-web/pull/33371)). Contributed by @MidhunSureshR. +* Room list: drag and drop rooms into sections ([#33366](https://github.com/element-hq/element-web/pull/33366)). Contributed by @florianduros. +* Call Tile - Render a tile showing that a call was started ([#32988](https://github.com/element-hq/element-web/pull/32988)). Contributed by @MidhunSureshR. +* Update button in incoming call toast to say 'Decline' ([#33405](https://github.com/element-hq/element-web/pull/33405)). Contributed by @robintown. +* Refactor and redesign user menu ([#32812](https://github.com/element-hq/element-web/pull/32812)). Contributed by @Half-Shot. + +## 🐛 Bug Fixes + +* Ensure interface gradually reduces visible buttons when viewport shrinks ([#33477](https://github.com/element-hq/element-web/pull/33477)). Contributed by @Half-Shot. +* Room list: add robustness to custom section loading ([#33475](https://github.com/element-hq/element-web/pull/33475)). Contributed by @florianduros. +* Make it possible to scroll overflowing hidden events again ([#33481](https://github.com/element-hq/element-web/pull/33481)). Contributed by @robintown. +* Fix user menu overlap with macos window controls ([#33425](https://github.com/element-hq/element-web/pull/33425)). Contributed by @dbkr. +* Visually indicate on hover that user menu can be clicked ([#33408](https://github.com/element-hq/element-web/pull/33408)). Contributed by @robintown. +* Fix TAC badges when hovered ([#33423](https://github.com/element-hq/element-web/pull/33423)). Contributed by @florianduros. + + +Changes in [1.12.18](https://github.com/element-hq/element-web/releases/tag/v1.12.18) (2026-05-12) +================================================================================================== +## ✨ Features + +* Room list: add collapse/expand all sections ([#33318](https://github.com/element-hq/element-web/pull/33318)). Contributed by @florianduros. +* Show user status in timeline ([#32991](https://github.com/element-hq/element-web/pull/32991)). Contributed by @Half-Shot. +* Disable URL Preview setting if disabled on the homeserver ([#33279](https://github.com/element-hq/element-web/pull/33279)). Contributed by @Half-Shot. +* Go to welcome on logout ([#33306](https://github.com/element-hq/element-web/pull/33306)). Contributed by @t3chguy. +* Room list: edit or remove custom sections ([#33283](https://github.com/element-hq/element-web/pull/33283)). Contributed by @florianduros. +* Re-generate QR code if the channel expires before scan ([#33303](https://github.com/element-hq/element-web/pull/33303)). Contributed by @t3chguy. +* Update toast styles, improve incoming call notifications ([#33043](https://github.com/element-hq/element-web/pull/33043)). Contributed by @robintown. +* Add Module Composer API ([#33284](https://github.com/element-hq/element-web/pull/33284)). Contributed by @Half-Shot. +* Room list: exclude default section from room list item menu ([#33278](https://github.com/element-hq/element-web/pull/33278)). Contributed by @florianduros. +* Show 'Verify this device' toast even if there are no encrypted rooms yet ([#32891](https://github.com/element-hq/element-web/pull/32891)). Contributed by @andybalaam. +* Promote "Share encrypted history" from labs ([#33281](https://github.com/element-hq/element-web/pull/33281)). Contributed by @richvdh. +* Room list: assign room to section when section is created ([#33240](https://github.com/element-hq/element-web/pull/33240)). Contributed by @florianduros. +* Confirm before inviting unknown users to a DM/room ([#33171](https://github.com/element-hq/element-web/pull/33171)). Contributed by @richvdh. +* Room list: assign room to custom section ([#33238](https://github.com/element-hq/element-web/pull/33238)). Contributed by @florianduros. +* Redesign link previews ([#33061](https://github.com/element-hq/element-web/pull/33061)). Contributed by @Half-Shot. +* Room list: scroll to newly creation section ([#33210](https://github.com/element-hq/element-web/pull/33210)). Contributed by @florianduros. + +## 🐛 Bug Fixes + +* Update home page CSS ([#32723](https://github.com/element-hq/element-web/pull/32723)). Contributed by @wolterkam. +* Web: Fix typo in `152x152` icon source of `manifest.json` ([#33369](https://github.com/element-hq/element-web/pull/33369)). Contributed by @bartvdbraak. +* prevent replay hover from restarting playback ([#33364](https://github.com/element-hq/element-web/pull/33364)). Contributed by @ZacksBot. +* Properly save `undefined` id tokens from OIDC login ([#33345](https://github.com/element-hq/element-web/pull/33345)). Contributed by @gingershaped. +* Show the right cursor when hovering over a space ([#33351](https://github.com/element-hq/element-web/pull/33351)). Contributed by @robintown. +* Set `type` in auth dict for `m.oauth` UIA stage ([#33344](https://github.com/element-hq/element-web/pull/33344)). Contributed by @gingershaped. +* Remove duplicated UI in appearance settings ([#33336](https://github.com/element-hq/element-web/pull/33336)). Contributed by @t3chguy. +* Move playwright-common wait-on from devDependencies to dependencies ([#33272](https://github.com/element-hq/element-web/pull/33272)). Contributed by @t3chguy. + + Changes in [1.12.17](https://github.com/element-hq/element-web/releases/tag/v1.12.17) (2026-04-30) ================================================================================================== ## 🐛 Bug Fixes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 615e496938..8e5b3de55c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,6 +140,10 @@ These are located in `/spec/` in `matrix-js-sdk` or `/test/` in `element-web`. When writing unit tests, please aim for a high level of test coverage for new code - 80% or greater. If you cannot achieve that, please document why it's not possible in your PR. +CI will validate that the coverage reached on your change is sufficient, +you can also assert this locally by installing https://github.com/Bachmann1234/diff_cover, +running the entire test suite in coverage mode `pnpm coverage` then running +`pnpm coverage:diff` to see the coverage of the diff between your HEAD and `develop`. Some sections of code are not sensible to add coverage for, such as those which explicitly inhibit noisy logging for tests. Which can be hidden using diff --git a/apps/desktop/.eslintrc.cjs b/apps/desktop/.eslintrc.cjs deleted file mode 100644 index 7a1d06729c..0000000000 --- a/apps/desktop/.eslintrc.cjs +++ /dev/null @@ -1,97 +0,0 @@ -module.exports = { - plugins: ["matrix-org", "n"], - extends: ["plugin:matrix-org/javascript"], - parserOptions: { - ecmaVersion: 2021, - project: ["tsconfig.json"], - }, - env: { - es6: true, - node: true, - // we also have some browser code (ie. the preload script) - browser: true, - }, - // NOTE: These rules are frozen and new rules should not be added here. - // New changes belong in https://github.com/matrix-org/eslint-plugin-matrix-org/ - rules: { - "quotes": "off", - "indent": "off", - "prefer-promise-reject-errors": "off", - "no-async-promise-executor": "off", - - "n/file-extension-in-import": ["error", "always"], - "unicorn/prefer-node-protocol": ["error"], - }, - overrides: [ - { - files: ["src/**/*.ts"], - extends: ["plugin:matrix-org/typescript"], - rules: { - // Things we do that break the ideal style - "prefer-promise-reject-errors": "off", - "quotes": "off", - - "@typescript-eslint/no-explicit-any": "off", - // We're okay with assertion errors when we ask for them - "@typescript-eslint/no-non-null-assertion": "off", - }, - }, - { - files: ["hak/**/*.ts"], - extends: ["plugin:matrix-org/typescript"], - parserOptions: { - project: ["hak/tsconfig.json"], - }, - rules: { - // Things we do that break the ideal style - "prefer-promise-reject-errors": "off", - "quotes": "off", - "n/file-extension-in-import": "off", - - "@typescript-eslint/no-explicit-any": "off", - // We're okay with assertion errors when we ask for them - "@typescript-eslint/no-non-null-assertion": "off", - }, - }, - { - files: ["scripts/**/*.ts"], - extends: ["plugin:matrix-org/typescript"], - parserOptions: { - project: ["scripts/tsconfig.json"], - }, - rules: { - // Things we do that break the ideal style - "prefer-promise-reject-errors": "off", - "quotes": "off", - "n/file-extension-in-import": "off", - - "@typescript-eslint/no-explicit-any": "off", - // We're okay with assertion errors when we ask for them - "@typescript-eslint/no-non-null-assertion": "off", - }, - }, - { - files: ["playwright/**/*.ts"], - extends: ["plugin:matrix-org/typescript"], - parserOptions: { - project: ["playwright/tsconfig.json"], - }, - rules: { - // Things we do that break the ideal style - "prefer-promise-reject-errors": "off", - "quotes": "off", - - "@typescript-eslint/no-explicit-any": "off", - // We're okay with assertion errors when we ask for them - "@typescript-eslint/no-non-null-assertion": "off", - }, - }, - { - files: ["src/**/*.test.ts", "electron-builder.ts", "vitest.config.ts"], - extends: ["plugin:matrix-org/typescript"], - parserOptions: { - project: ["tsconfig.node.json"], - }, - }, - ], -}; diff --git a/apps/desktop/.lintstagedrc b/apps/desktop/.lintstagedrc deleted file mode 100644 index 673a390518..0000000000 --- a/apps/desktop/.lintstagedrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "*": "prettier --write --ignore-unknown", - "*.(ts|tsx)": ["eslint --fix"] -} diff --git a/apps/desktop/.node-version b/apps/desktop/.node-version index 5bf4400f22..8dfc5cb1af 100644 --- a/apps/desktop/.node-version +++ b/apps/desktop/.node-version @@ -1 +1 @@ -24.15.0 +24.18.1 diff --git a/apps/desktop/.prettierrc.cjs b/apps/desktop/.prettierrc.cjs deleted file mode 100644 index 6a17910f1a..0000000000 --- a/apps/desktop/.prettierrc.cjs +++ /dev/null @@ -1 +0,0 @@ -module.exports = require("eslint-plugin-matrix-org/.prettierrc.js"); diff --git a/apps/desktop/README.md b/apps/desktop/README.md index e502355a12..5c7d4874fb 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -1,9 +1,4 @@ -![Build](https://github.com/vector-im/element-desktop/actions/workflows/build_desktop_and_deploy.yaml/badge.svg) -![Static Analysis](https://github.com/vector-im/element-desktop/actions/workflows/static_analysis.yaml/badge.svg) -[![Localazy](https://img.shields.io/endpoint?url=https%3A%2F%2Fconnect.localazy.com%2Fstatus%2Felement-web%2Fdata%3Fcontent%3Dall%26title%3Dlocalazy%26logo%3Dtrue)](https://localazy.com/p/element-web) -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=element-desktop&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=element-desktop) -[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=element-desktop&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=element-desktop) -[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=element-desktop&metric=bugs)](https://sonarcloud.io/summary/new_code?id=element-desktop) +![Build](https://github.com/element-hq/element-web/actions/workflows/build_desktop_and_deploy.yaml/badge.svg) # Element Desktop @@ -63,7 +58,7 @@ ln -s ../web/webapp ./ TODO: List native pre-requisites -Optionally, [build the native modules](https://github.com/vector-im/element-desktop/blob/develop/docs/native-node-modules.md), +Optionally, [build the native modules](https://github.com/element-hq/element-web/blob/develop/docs/native-node-modules.md), which include support for searching in encrypted rooms and secure storage. Skipping this step is fine, you just won't have those features. Then, run diff --git a/apps/desktop/babel.config.cjs b/apps/desktop/babel.config.cjs index 9545b5983d..c603e3cf98 100644 --- a/apps/desktop/babel.config.cjs +++ b/apps/desktop/babel.config.cjs @@ -1,3 +1,10 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + module.exports = { presets: [["@babel/preset-env", { targets: { node: "current" } }], "@babel/preset-typescript"], }; diff --git a/apps/desktop/dockerbuild/Dockerfile b/apps/desktop/dockerbuild/Dockerfile index c529c5619b..792e677947 100644 --- a/apps/desktop/dockerbuild/Dockerfile +++ b/apps/desktop/dockerbuild/Dockerfile @@ -1,10 +1,17 @@ +# syntax=docker.io/docker/dockerfile:1.25-labs@sha256:4426b5e269e36911b94fb79cf67f1fd7155ef11b2bbc8ab23cbfcbc97130efe9 # Docker image to facilitate building Element Desktop's native bits using a glibc version (2.31) # with broader compatibility, down to Debian bullseye & Ubuntu focal. -FROM rust:bullseye@sha256:85f9d38ab80fa5752a6fd5bff34c953a59ce2c7ccb0d47fb678d3c0300b8a331 +# PNPM source +FROM ghcr.io/pnpm/pnpm:11.10.0@sha256:9a6eb06d5f861d830fe27d85a91415e60527fa45ec45b52ee43c92a8aaf3bf8a AS pnpm + +FROM rust:bullseye@sha256:33e0cd779c556a248ac05acabb695c1da0cffaccc1796da507451482ac94d277 ENV DEBIAN_FRONTEND=noninteractive +COPY --from=pnpm /opt/pnpm /opt/pnpm +RUN ln -s /opt/pnpm/pnpm /usr/local/bin/pnpm + RUN apt-get -qq update && apt-get -y -qq dist-upgrade && \ apt-get -y -qq install --no-install-recommends \ # tclsh is required for building SQLite as part of SQLCipher @@ -16,8 +23,9 @@ RUN ln -s /usr/bin/python3 /usr/bin/python & ln -s /usr/bin/pip3 /usr/bin/pip ENV DEBUG_COLORS=true ENV FORCE_COLOR=true +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 -WORKDIR /project +WORKDIR /project/apps/desktop ARG TARGETOS ARG TARGETARCH diff --git a/apps/desktop/dockerbuild/setup.sh b/apps/desktop/dockerbuild/setup.sh index a1b89d0faa..1b01e8ee69 100755 --- a/apps/desktop/dockerbuild/setup.sh +++ b/apps/desktop/dockerbuild/setup.sh @@ -9,4 +9,5 @@ ARCH="${archMap["$TARGETARCH"]}" NODE_VERSION=$(cat /.node-version | sed -e 's/^v//') curl --proto "=https" -L "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-$TARGETOS-$ARCH.tar.gz" | tar xz -C /usr/local --strip-components=1 && \ unlink /usr/local/CHANGELOG.md && unlink /usr/local/LICENSE && unlink /usr/local/README.md -corepack enable \ No newline at end of file +# We need yarn classic to build seshat +npm install --global yarn@1 diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index 08b5cef03a..174fe991d4 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -1,7 +1,15 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + import * as os from "node:os"; import * as fs from "node:fs"; -import * as path from "node:path"; -import { type Configuration as BaseConfiguration } from "electron-builder"; +import path from "node:path"; +import { type Configuration as BaseConfiguration, log } from "electron-builder"; +import { LogMessageByKey } from "app-builder-lib/out/node-module-collector/moduleManager.js"; /** * This script has different outputs depending on your os platform. @@ -70,7 +78,7 @@ if (process.env.VARIANT_PATH) { console.log(`Using variant configuration from '${process.env.VARIANT_PATH}':`); variant = { ...variant, - ...JSON.parse(fs.readFileSync(`${process.env.VARIANT_PATH}`, "utf8")), + ...JSON.parse(fs.readFileSync(process.env.VARIANT_PATH, "utf8")), }; } else { console.warn(`No VARIANT_PATH specified, using default variant configuration '${DEFAULT_VARIANT}':`); @@ -259,4 +267,17 @@ if (os.platform() === "linux") { } } +// Treat certain warnings as a fatal error +const FATAL_WARNINGS = [LogMessageByKey.PKG_NOT_ON_DISK, LogMessageByKey.PKG_NOT_FOUND]; +// Otherwise we just burn time running the tests for no reason. +if (typeof log !== "undefined") { + const prevTransform = log.messageTransformer; + log.messageTransformer = (message, level) => { + if (level === "warn" && FATAL_WARNINGS.some((w) => message.startsWith(w))) { + throw new Error(`electron-builder: ${message}`); + } + return prevTransform?.(message, level) ?? message; + }; +} + export default config; diff --git a/apps/desktop/element.io/nightly/config.json b/apps/desktop/element.io/nightly/config.json index 3cb23b0b5e..9c0cecfaa5 100644 --- a/apps/desktop/element.io/nightly/config.json +++ b/apps/desktop/element.io/nightly/config.json @@ -51,15 +51,11 @@ "features": { "threadsActivityCentre": true, "feature_spotlight": true, - "feature_group_calls": true, "feature_video_rooms": true, "feature_element_call_video_rooms": true }, "setting_defaults": { "RustCrypto.staged_rollout_percent": 100 }, - "element_call": { - "url": "https://call.element.dev" - }, "map_style_url": "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx" } diff --git a/apps/desktop/element.io/release/config.json b/apps/desktop/element.io/release/config.json index 9bc948b2b6..b8aed8743d 100644 --- a/apps/desktop/element.io/release/config.json +++ b/apps/desktop/element.io/release/config.json @@ -50,10 +50,6 @@ }, "features": { "feature_video_rooms": true, - "feature_group_calls": true, "feature_element_call_video_rooms": true - }, - "element_call": { - "url": "https://call.element.io" } } diff --git a/apps/desktop/hak/matrix-seshat/check.ts b/apps/desktop/hak/matrix-seshat/check.ts index 99a107eb5a..dba992e6fc 100644 --- a/apps/desktop/hak/matrix-seshat/check.ts +++ b/apps/desktop/hak/matrix-seshat/check.ts @@ -48,8 +48,9 @@ export default async function (hakEnv: HakEnv, moduleInfo: DependencyInfo): Prom "` " + "or your package manager if not using `rustup`", ); + return; } - fsProm.unlink("tmp").then(resolve); + resolve(fsProm.unlink("tmp")); }, ); rustc.stdin!.write("fn main() {}"); diff --git a/apps/desktop/hak/tsconfig.json b/apps/desktop/hak/tsconfig.json index b762dda71a..2a3a2f7bdd 100644 --- a/apps/desktop/hak/tsconfig.json +++ b/apps/desktop/hak/tsconfig.json @@ -8,7 +8,9 @@ "strict": true, "lib": ["es2022"], "types": ["node"], - "allowImportingTsExtensions": true + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "noEmit": true }, "include": ["../scripts/@types/*.d.ts", "./**/*.ts"] } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f32776f09a..dec0f590f1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -3,7 +3,7 @@ "productName": "ThreadNet", "main": "lib/electron-main.js", "exports": "./lib/electron-main.js", - "version": "1.12.17", + "version": "1.12.26", "description": "Element: the future of secure communication", "author": { "name": "Element", @@ -23,15 +23,13 @@ "scripts": { "i18n": "matrix-gen-i18n && pnpm i18n:sort && pnpm i18n:lint", "i18n:sort": "matrix-sort-i18n src/i18n/strings/en_EN.json", - "i18n:lint": "prettier --log-level=silent --write src/i18n/strings/ --ignore-path /dev/null", + "i18n:lint": "oxfmt src/i18n/strings/", "i18n:diff": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && pnpm i18n && matrix-compare-i18n-files src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json", "mkdirs": "mkdirp packages deploys", "fetch": "pnpm run mkdirs && node scripts/fetch-package.ts", "asar-webapp": "asar p webapp webapp.asar", "start": "nx start", - "lint": "pnpm lint:types && pnpm lint:js", - "lint:js": "eslint --max-warnings 0 src hak playwright scripts", - "lint:js-fix": "eslint --fix --max-warnings 0 src hak playwright scripts && prettier --log-level=warn --write .", + "lint": "pnpm lint:types", "lint:types": "pnpm lint:types:src && pnpm lint:types:node && pnpm lint:types:test && pnpm lint:types:scripts && pnpm lint:types:hak", "lint:types:src": "tsc --noEmit", "lint:types:node": "tsc --noEmit -p tsconfig.node.json", @@ -47,13 +45,15 @@ "docker:setup": "docker build --platform linux/amd64 -t element-desktop-dockerbuild -f dockerbuild/Dockerfile .", "docker:build:native": "scripts/in-docker.sh pnpm run hak", "docker:build": "scripts/in-docker.sh pnpm run build", - "docker:install": "scripts/in-docker.sh pnpm install", + "docker:install": "scripts/in-docker.sh pnpm install --filter=element-desktop --frozen-lockfile", "clean": "rimraf webapp.asar dist packages deploys lib", "hak": "node scripts/hak/index.ts", "test:unit": "vitest", "test:playwright": "nx test:playwright --", "test:playwright:open": "nx test:playwright -- --ui", "test:playwright:screenshots": "nx test:playwright:screenshots --", + "coverage": "pnpm test:unit --coverage", + "coverage:diff": "diff-cover --config-file ../../diff-cover.toml coverage/lcov.info", "sane-postinstall": "electron-builder install-app-deps" }, "dependencies": { @@ -70,50 +70,39 @@ "@babel/core": "^7.18.10", "@babel/preset-env": "^7.18.10", "@babel/preset-typescript": "^7.18.6", - "@electron/asar": "4.2.0", + "@electron/asar": "4.2.1", "@electron/fuses": "^2.1.1", - "@element-hq/vite-common": "workspace:*", "@playwright/test": "catalog:", - "@stylistic/eslint-plugin": "^5.0.0", "@types/auto-launch": "^5.0.1", "@types/counterpart": "^0.18.1", "@types/minimist": "^1.2.1", - "@types/node": "18.19.130", + "@types/node": "catalog:", "@types/pacote": "^11.1.1", - "@typescript-eslint/eslint-plugin": "^8.0.0", - "@typescript-eslint/parser": "^8.0.0", - "@vitest/coverage-v8": "catalog:", - "app-builder-lib": "26.9.1", + "@typescript/native": "catalog:", + "app-builder-lib": "26.15.3", "chokidar": "^5.0.0", "detect-libc": "^2.0.0", - "electron": "42.0.0", - "electron-builder": "26.9.1", - "electron-builder-squirrel-windows": "26.9.1", + "electron": "43.2.0", + "electron-builder": "26.15.3", + "electron-builder-squirrel-windows": "26.15.3", "electron-devtools-installer": "^4.0.0", - "eslint": "^8.26.0", - "eslint-config-google": "^0.14.0", - "eslint-config-prettier": "^10.0.0", - "eslint-plugin-import": "^2.25.4", - "eslint-plugin-matrix-org": "^3.0.0", - "eslint-plugin-n": "^17.12.0", - "eslint-plugin-unicorn": "^56.0.0", "glob": "^13.0.0", "matrix-web-i18n": "catalog:", "memfs": "^4.57.2", "mkdirp": "^3.0.0", - "pacote": "^21.0.0", - "prettier": "^3.0.0", + "pacote": "^22.0.0", "rimraf": "^6.0.0", + "shared-types": "workspace:*", "tar": "^7.5.8", - "typescript": "6.0.3", - "vitest": "catalog:", - "vitest-sonar-reporter": "catalog:" + "typescript": "catalog:ts6", + "vitest": "catalog:" }, "hakDependencies": { - "matrix-seshat": "4.2.0" + "matrix-seshat": "5.0.0" }, - "packageManager": "pnpm@10.33.3+sha512.a19744364a7e248b92657a4ca5973f9354d21caf982579674b1c539f32c7420c47138ad8b1254df07aba9bc782d9b3029e3db34d5dbff974326eb74dac8ff489", "nx": { - "includedScripts": [] + "includedScripts": [ + "lint:types" + ] } } diff --git a/apps/desktop/playwright/Dockerfile b/apps/desktop/playwright/Dockerfile index 212eeb3438..6e4ac756b4 100644 --- a/apps/desktop/playwright/Dockerfile +++ b/apps/desktop/playwright/Dockerfile @@ -1,14 +1,21 @@ -FROM mcr.microsoft.com/playwright:v1.59.1-jammy@sha256:8a0360d39d1973be506dd59002904a774f6d697d4946c94063b3fd006461c8ff +# syntax=docker.io/docker/dockerfile:1.25-labs@sha256:4426b5e269e36911b94fb79cf67f1fd7155ef11b2bbc8ab23cbfcbc97130efe9 + +# PNPM source +FROM ghcr.io/pnpm/pnpm:11.10.0@sha256:9a6eb06d5f861d830fe27d85a91415e60527fa45ec45b52ee43c92a8aaf3bf8a AS pnpm + +FROM mcr.microsoft.com/playwright:v1.61.1-jammy@sha256:7b86926fff94374389e8e1f4fdc5c76d050d4a06a7886bb537bf412b20e2b71e WORKDIR /work +COPY --from=pnpm /opt/pnpm /opt/pnpm +RUN ln -s /opt/pnpm/pnpm /usr/local/bin/pnpm + RUN apt-get update && \ apt-get -y install xvfb dbus-x11 && \ apt-get purge -y --auto-remove && \ rm -rf /var/lib/apt/lists/* && \ - corepack enable + npm install -g pnpm@latest-11 -ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 ENV GITHUB_ACTIONS=1 ENV DEBUG=pw:browser diff --git a/apps/desktop/playwright/e2e/launch/config-options.spec.ts b/apps/desktop/playwright/e2e/launch/config-options.spec.ts index 39d7c1d584..db5e91711c 100644 --- a/apps/desktop/playwright/e2e/launch/config-options.spec.ts +++ b/apps/desktop/playwright/e2e/launch/config-options.spec.ts @@ -5,19 +5,16 @@ 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 { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { test, expect } from "../../element-desktop-test.js"; -const __dirname = dirname(fileURLToPath(import.meta.url)); - test.describe("App config options", () => { test.describe("Should load custom config via env", () => { test.slow(); test.use({ extraEnv: { - ELEMENT_DESKTOP_CONFIG_JSON: resolve(__dirname, "../..", "fixtures/custom-config.json"), + ELEMENT_DESKTOP_CONFIG_JSON: fileURLToPath(import.meta.resolve("../../fixtures/custom-config.json")), }, }); test("should launch and use configured homeserver", async ({ page }) => { @@ -31,7 +28,7 @@ test.describe("App config options", () => { test.describe("Should load custom config via argument", () => { test.slow(); test.use({ - extraArgs: ["--config", resolve(__dirname, "../..", "fixtures/custom-config.json")], + extraArgs: ["--config", fileURLToPath(import.meta.resolve("../../fixtures/custom-config.json"))], }); test("should launch and use configured homeserver", async ({ page }) => { await page.locator("#matrixchat").waitFor(); diff --git a/apps/desktop/playwright/e2e/launch/oidc.spec.ts b/apps/desktop/playwright/e2e/launch/oidc.spec.ts index c741217a58..f366dd2e5e 100644 --- a/apps/desktop/playwright/e2e/launch/oidc.spec.ts +++ b/apps/desktop/playwright/e2e/launch/oidc.spec.ts @@ -9,7 +9,7 @@ import { test, expect } from "../../element-desktop-test.js"; declare global { interface ElectronPlatform { - getOidcCallbackUrl(): URL; + getOAuthCallbackUrl(): URL; } interface Window { @@ -29,7 +29,7 @@ test.describe("OIDC Native", () => { test("should use OIDC callback URL without authority component", async ({ page }) => { await expect( page.evaluate(() => { - return window.mxPlatformPeg.get().getOidcCallbackUrl().toString(); + return window.mxPlatformPeg.get().getOAuthCallbackUrl().toString(); }), ).resolves.toMatch(/io\.element\.(desktop|nightly):\/vector\/webapp\//); }); diff --git a/apps/desktop/playwright/element-desktop-test.ts b/apps/desktop/playwright/element-desktop-test.ts index b2fa3c983e..86ca5d1a72 100644 --- a/apps/desktop/playwright/element-desktop-test.ts +++ b/apps/desktop/playwright/element-desktop-test.ts @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import { _electron as electron, test as base, expect as baseExpect, type ElectronApplication } from "@playwright/test"; import fs from "node:fs/promises"; -import path, { dirname } from "node:path"; +import path from "node:path"; import os from "node:os"; import { fileURLToPath } from "node:url"; import { PassThrough } from "node:stream"; @@ -44,22 +44,19 @@ interface Fixtures { stderr: CapturedPassThrough; } -const __dirname = dirname(fileURLToPath(import.meta.url)); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); export const test = base.extend({ extraEnv: {}, extraArgs: [], - // eslint-disable-next-line no-empty-pattern stdout: async ({}, use) => { await use(new CapturedPassThrough()); }, - // eslint-disable-next-line no-empty-pattern stderr: async ({}, use) => { await use(new CapturedPassThrough()); }, - // eslint-disable-next-line no-empty-pattern tmpDir: async ({}, use) => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "element-desktop-tests-")); await use(tmpDir); diff --git a/apps/desktop/scripts/copy-res.ts b/apps/desktop/scripts/copy-res.ts index 18ce877d11..e8ac13b60f 100644 --- a/apps/desktop/scripts/copy-res.ts +++ b/apps/desktop/scripts/copy-res.ts @@ -1,10 +1,17 @@ #!/usr/bin/env node +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + // copies resources into the lib directory. import parseArgs from "minimist"; import * as chokidar from "chokidar"; -import * as path from "node:path"; +import path from "node:path"; import * as fs from "node:fs"; const argv = parseArgs(process.argv.slice(2), {}); diff --git a/apps/desktop/scripts/fetch-package.ts b/apps/desktop/scripts/fetch-package.ts index 21876c2a69..2d882f29f1 100644 --- a/apps/desktop/scripts/fetch-package.ts +++ b/apps/desktop/scripts/fetch-package.ts @@ -1,6 +1,12 @@ #!/usr/bin/env node +/* +Copyright 2026 Element Creations Ltd. -import * as path from "node:path"; +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import path from "node:path"; import { createWriteStream, promises as fs } from "node:fs"; import * as childProcess from "node:child_process"; import * as tar from "tar"; diff --git a/apps/desktop/scripts/generate-nightly-version.ts b/apps/desktop/scripts/generate-nightly-version.ts index eef0ab997b..aa8245d99e 100644 --- a/apps/desktop/scripts/generate-nightly-version.ts +++ b/apps/desktop/scripts/generate-nightly-version.ts @@ -1,4 +1,10 @@ #!/usr/bin/env node +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ /** * Script to generate incremental Nightly build versions, based on the latest Nightly build version of that kind. diff --git a/apps/desktop/scripts/get-version.ts b/apps/desktop/scripts/get-version.ts index 2d28b3a24d..90e2b4d9be 100644 --- a/apps/desktop/scripts/get-version.ts +++ b/apps/desktop/scripts/get-version.ts @@ -1,4 +1,10 @@ #!/usr/bin/env node +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ /* * Checks for the presence of a webapp, inspects its version and prints it diff --git a/apps/desktop/scripts/hak/copy.ts b/apps/desktop/scripts/hak/copy.ts index dd266201cf..167aa8ef0b 100644 --- a/apps/desktop/scripts/hak/copy.ts +++ b/apps/desktop/scripts/hak/copy.ts @@ -27,7 +27,7 @@ export default async function copy(hakEnv: HakEnv, moduleInfo: DependencyInfo): if (moduleInfo.moduleBuildDirs.length > 1) { if (!hakEnv.isMac()) { console.error( - "You asked me to copy multiple targets but I've only been taught " + "how to do that on macOS.", + "You asked me to copy multiple targets but I've only been taught how to do that on macOS.", ); throw new Error("Can't copy multiple targets on this platform"); } diff --git a/apps/desktop/scripts/hak/hakEnv.ts b/apps/desktop/scripts/hak/hakEnv.ts index 4ce382dc11..f3194f4f09 100644 --- a/apps/desktop/scripts/hak/hakEnv.ts +++ b/apps/desktop/scripts/hak/hakEnv.ts @@ -14,7 +14,7 @@ import childProcess, { type SpawnOptions } from "node:child_process"; import { type Arch, type Target, TARGETS, getHost, isHostId, type TargetId } from "./target.ts"; async function getRuntimeVersion(projectRoot: string): Promise { - const electronVersion = await getElectronVersionFromInstalled(path.join(projectRoot, "..", "..")); + const electronVersion = await getElectronVersionFromInstalled(projectRoot); if (!electronVersion) { throw new Error("Can't determine Electron version"); } diff --git a/apps/desktop/scripts/hak/index.ts b/apps/desktop/scripts/hak/index.ts index ea02582632..a48ac96f6b 100644 --- a/apps/desktop/scripts/hak/index.ts +++ b/apps/desktop/scripts/hak/index.ts @@ -6,7 +6,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 path, { dirname } from "node:path"; +import path from "node:path"; import { fileURLToPath } from "node:url"; import HakEnv from "./hakEnv.ts"; @@ -28,7 +28,7 @@ const METACOMMANDS: Record = { // Scripts valid in a hak.json 'scripts' section const HAKSCRIPTS = ["check", "fetch", "build"]; -const __dirname = dirname(fileURLToPath(import.meta.url)); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function main(): Promise { const prefix = path.join(__dirname, "..", ".."); @@ -38,7 +38,6 @@ async function main(): Promise { // Can be specified multiple times for the copy command to bundle // multiple arches into a single universal output module) for (;;) { - // eslint-disable-line no-constant-condition const targetIndex = process.argv.indexOf("--target"); if (targetIndex === -1) break; @@ -132,7 +131,7 @@ async function main(): Promise { for (const mod of modules) { const depInfo = deps[mod]; if (depInfo === undefined) { - console.log("Module " + mod + " not found - is it in hakDependencies " + "in your package.json?"); + console.log(`Module ${mod} not found - is it in hakDependencies in your package.json?`); process.exit(1); } console.log("hak " + cmd + ": " + mod); diff --git a/apps/desktop/scripts/in-docker.sh b/apps/desktop/scripts/in-docker.sh index 3df278bcb5..c358c3776f 100755 --- a/apps/desktop/scripts/in-docker.sh +++ b/apps/desktop/scripts/in-docker.sh @@ -12,7 +12,11 @@ if [ $? != 0 ]; then exit 1 fi -mkdir -p docker/node_modules docker/.hak docker/.gnupg +mkdir -p \ + docker/workspace_node_modules \ + docker/node_modules \ + docker/.hak \ + docker/.gnupg # Taken from https://www.electron.build/multi-platform-build#docker # Pass through any vars prefixed with INDOCKER_, removing the prefix @@ -21,9 +25,10 @@ docker run --rm -ti \ --env-file <(env | grep -E '^INDOCKER_' | sed -e 's/^INDOCKER_//') \ --env ELECTRON_CACHE="/root/.cache/electron" \ --env ELECTRON_BUILDER_CACHE="/root/.cache/electron-builder" \ - -v ${PWD}:/project \ - -v ${PWD}/docker/node_modules:/project/node_modules \ - -v ${PWD}/docker/.hak:/project/.hak \ + -v ${PWD}/../../:/project \ + -v ${PWD}/docker/workspace_node_modules:/project/node_modules \ + -v ${PWD}/docker/node_modules:/project/apps/desktop/node_modules \ + -v ${PWD}/docker/.hak:/project/apps/desktop/.hak \ -v ${PWD}/docker/.gnupg:/root/.gnupg \ -v ~/.cache/electron:/root/.cache/electron \ -v ~/.cache/electron-builder:/root/.cache/electron-builder \ diff --git a/apps/desktop/scripts/set-version.ts b/apps/desktop/scripts/set-version.ts index 01e5027802..8f5041c1d3 100644 --- a/apps/desktop/scripts/set-version.ts +++ b/apps/desktop/scripts/set-version.ts @@ -1,4 +1,10 @@ #!/usr/bin/env node +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ /* * Checks for the presence of a webapp, inspects its version and sets the diff --git a/apps/desktop/scripts/tsconfig.json b/apps/desktop/scripts/tsconfig.json index bff3b8ae81..5b5a8b7096 100644 --- a/apps/desktop/scripts/tsconfig.json +++ b/apps/desktop/scripts/tsconfig.json @@ -10,7 +10,8 @@ "strict": true, "lib": ["es2022"], "types": ["node"], - "allowImportingTsExtensions": true + "allowImportingTsExtensions": true, + "noEmit": true }, "include": ["../src/@types", "./**/*.ts"] } diff --git a/apps/desktop/src/@types/global.d.ts b/apps/desktop/src/@types/global.d.ts index 840cc92077..3cba8d2fcc 100644 --- a/apps/desktop/src/@types/global.d.ts +++ b/apps/desktop/src/@types/global.d.ts @@ -10,13 +10,8 @@ import { type BrowserWindow } from "electron"; import { type AppLocalization } from "../language-helper.js"; // global type extensions need to use var for whatever reason -/* eslint-disable no-var */ declare global { - type IConfigOptions = Record; - var mainWindow: BrowserWindow | null; var appQuitting: boolean; var appLocalization: AppLocalization; - var vectorConfig: IConfigOptions; } -/* eslint-enable no-var */ diff --git a/apps/desktop/src/@types/matrix-seshat.d.ts b/apps/desktop/src/@types/matrix-seshat.d.ts index e93147b673..d68846fd2c 100644 --- a/apps/desktop/src/@types/matrix-seshat.d.ts +++ b/apps/desktop/src/@types/matrix-seshat.d.ts @@ -11,7 +11,6 @@ declare module "matrix-seshat" { passphrase?: string; } - /* eslint-disable camelcase */ interface IMatrixEvent { event_id: string; sender: string; @@ -49,7 +48,6 @@ declare module "matrix-seshat" { context: ISearchContext; }>; } - /* eslint-enable camelcase */ interface ICheckpoint { roomId: string; diff --git a/apps/desktop/src/args.test.ts b/apps/desktop/src/args.test.ts new file mode 100644 index 0000000000..58e189185a --- /dev/null +++ b/apps/desktop/src/args.test.ts @@ -0,0 +1,323 @@ +/* +Copyright 2026 Element Creations 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 { expect, describe, it, beforeEach, vi } from "vitest"; +import { fs as memfs, vol } from "memfs"; +import { app } from "electron"; + +import { type Args, getArgs, getArgsForProtocolRegistration } from "./args.js"; +import type ProtocolHandler from "./protocol.js"; + +vi.mock("node:fs", () => ({ default: memfs })); +vi.mock("electron", () => ({ + app: { + getPath: vi.fn().mockImplementation((dirName) => { + if (dirName === "userData") return "/Users/name/Library/Application Support/Element"; + if (dirName === "appData") return "/Users/name/Library/Application Support"; + throw new Error("Not implemented"); + }), + getName: vi.fn().mockReturnValue("Element"), + exit: vi.fn(), + }, +})); + +beforeEach(() => { + // Reset the state of the in-memory fs + vol.reset(); + vi.restoreAllMocks(); + vi.clearAllMocks(); +}); + +describe("getArgsForProtocolRegistration", () => { + it("should return an empty array for default args", () => { + expect( + getArgsForProtocolRegistration({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: true, + hidden: false, + positional: [], + }), + ).toStrictEqual([]); + }); + + it("should handle standard args", () => { + expect( + getArgsForProtocolRegistration({ + userDataPath: "/Users/name/Library/Application Support/Custom", + localConfigPath: "/root/config.json", + devtools: true, + update: false, + hidden: false, + positional: [], + }), + ).toStrictEqual([ + "--no-update", + "--config", + "/root/config.json", + "--profile-dir", + "/Users/name/Library/Application Support/Custom", + ]); + }); + + it("should ignore hidden=true", () => { + expect( + getArgsForProtocolRegistration({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: true, + hidden: true, + positional: [], + }), + ).toStrictEqual([]); + }); + + it("should ignore positional args", () => { + expect( + getArgsForProtocolRegistration({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: true, + hidden: false, + positional: ["element://foobar"], + }), + ).toStrictEqual([]); + }); +}); + +describe("getArgs", () => { + function run(...args: string[]): Args { + vi.spyOn(process, "argv", "get").mockReturnValue(["/path/to/app", ...args]); + const mockProtocolHandler = { + getProfileFromDeeplink: vi.fn(), + } as unknown as ProtocolHandler; + return getArgs(mockProtocolHandler); + } + + it("should handle '--help'", () => { + const args = run("--help"); + expect(args).toEqual({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + }); + expect(app.exit).toHaveBeenCalled(); + }); + + it("should handle no command line args", () => { + const args = run(); + expect(args).toEqual({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + }); + expect(app.exit).not.toHaveBeenCalled(); + }); + + it("should handle '--hidden'", () => { + const args = run("--hidden"); + expect(args).toEqual({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: true, + hidden: true, + positional: ["/path/to/app"], + }); + }); + + it("should handle '--no-update'", () => { + const args = run("--no-update"); + expect(args).toEqual({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: false, + hidden: false, + positional: ["/path/to/app"], + }); + }); + + describe("storageMode", () => { + it("should handle valid '--storage-mode'", () => { + const args = run("--storage-mode=force-plaintext"); + expect(args).toEqual({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + storageMode: "force-plaintext", + }); + }); + + it("should ignore invalid '--storage-mode'", () => { + const args = run("--storage-mode=magic"); + expect(args).toEqual({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + storageMode: undefined, + }); + }); + }); + + describe("userDataPath", () => { + it("should handle deeplinks", () => { + vi.spyOn(process, "argv", "get").mockReturnValue([ + "/path/to/app", + "protocol:/#state=foo:element-desktop-ssoid:XXYYZZ&code=bar", + ]); + const mockProtocolHandler = { + getProfileFromDeeplink: vi.fn().mockReturnValue("/path/to/deeplinked-profile"), + } as unknown as ProtocolHandler; + const args = getArgs(mockProtocolHandler); + + expect(mockProtocolHandler.getProfileFromDeeplink).toHaveBeenCalledWith(process.argv); + expect(args).toEqual({ + userDataPath: "/path/to/deeplinked-profile", + devtools: false, + update: true, + hidden: false, + positional: [...process.argv], + }); + }); + + it("should handle '--profile-dir'", () => { + const args = run("--profile-dir", "/path/to/profile"); + expect(args).toEqual({ + userDataPath: "/path/to/profile", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + }); + }); + + it("should handle '--profile'", () => { + const args = run("--profile", "work"); + expect(args).toEqual({ + userDataPath: "/Users/name/Library/Application Support/Element-work", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + }); + }); + + it("should handle ELEMENT_PROFILE_DIR", () => { + vi.spyOn(process, "env", "get").mockReturnValue({ + ELEMENT_PROFILE_DIR: "/mnt/foo/profile", + }); + const args = run(); + expect(args).toEqual({ + userDataPath: "/mnt/foo/profile", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + }); + }); + + it("should prefer deeplink over ELEMENT_PROFILE_DIR", () => { + vi.spyOn(process, "argv", "get").mockReturnValue(["/path/to/app", "protocol:/#state=foo&code=bar"]); + vi.spyOn(process, "env", "get").mockReturnValue({ + ELEMENT_PROFILE_DIR: "/mnt/foo/profile", + }); + const mockProtocolHandler = { + getProfileFromDeeplink: vi.fn().mockReturnValue("/path/to/deeplinked-profile"), + } as unknown as ProtocolHandler; + const args = getArgs(mockProtocolHandler); + + expect(mockProtocolHandler.getProfileFromDeeplink).toHaveBeenCalledWith(process.argv); + expect(args).toEqual({ + userDataPath: "/path/to/deeplinked-profile", + devtools: false, + update: true, + hidden: false, + positional: [...process.argv], + }); + }); + + it("should combine ELEMENT_PROFILE_DIR with '--profile'", () => { + vi.spyOn(process, "env", "get").mockReturnValue({ + ELEMENT_PROFILE_DIR: "/mnt/foo/profile", + }); + const args = run("--profile", "play"); + expect(args).toEqual({ + userDataPath: "/mnt/foo/profile-play", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + }); + }); + + it("should handle old Riot data dirs", () => { + vol.fromJSON({ + "/Users/name/Library/Application Support/Riot/IndexedDB": "This is a real IDB. I promise.", + }); + + const args = run(); + expect(args).toEqual({ + userDataPath: "/Users/name/Library/Application Support/Riot", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + }); + }); + }); + + describe("localConfigPath", () => { + it("should handle '--config'", () => { + const args = run("--config", "/path/to/config.json"); + expect(args).toEqual({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + localConfigPath: "/path/to/config.json", + }); + }); + + it("should handle ELEMENT_DESKTOP_CONFIG_JSON", () => { + vi.spyOn(process, "env", "get").mockReturnValue({ + ELEMENT_DESKTOP_CONFIG_JSON: "/path/for/config.json", + }); + const args = run(); + expect(args).toEqual({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + localConfigPath: "/path/for/config.json", + }); + }); + + it("should prefer arg over env", () => { + vi.spyOn(process, "env", "get").mockReturnValue({ + ELEMENT_DESKTOP_CONFIG_JSON: "/path/for/config.json", + }); + const args = run("--config", "/path/to/config.json"); + expect(args).toEqual({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + localConfigPath: "/path/to/config.json", + }); + }); + }); +}); diff --git a/apps/desktop/src/args.ts b/apps/desktop/src/args.ts new file mode 100644 index 0000000000..4f4f8fec52 --- /dev/null +++ b/apps/desktop/src/args.ts @@ -0,0 +1,165 @@ +/* +Copyright 2026 Element Creations 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 minimist, { type ParsedArgs } from "minimist"; +import { app } from "electron"; +import fs from "node:fs"; +import path from "node:path"; + +import { Mode } from "./store.js"; +import type ProtocolHandler from "./protocol.js"; + +const defaultUserDataDir = app.getPath("userData"); + +/** + * Calculates the command line arguments to include in the protocol registration, + * some parameters, e.g. '--hidden' are omitted as it'd cause the app to not be focused. + * Excludes all positional arguments as those are only relevant once, e.g. for OIDC auth callbacks. + * Includes unknown parameters as they are sometimes handled by Electron, e.g. `--proxy`. + * @param parsedArgs - the args the application was started with + */ +export function getArgsForProtocolRegistration(parsedArgs: Args): string[] { + const args: string[] = []; + + if (!parsedArgs.update) { + args.push("--no-update"); + } + + if (parsedArgs.localConfigPath) { + args.push("--config", parsedArgs.localConfigPath); + } + + if (parsedArgs.userDataPath != defaultUserDataDir) { + args.push("--profile-dir", parsedArgs.userDataPath); + } + + return args; +} + +/** + * Element Desktop launch args, returned by {@link getArgs} + */ +export interface Args { + /** + * Path to user data, root of all persistent data for this profile. + */ + userDataPath: string; + /** + * Path to local override config.json file. + */ + localConfigPath?: string; + /** + * The store {@link Mode} to use. + */ + storageMode?: Mode; + /** + * Whether to install devtools. + */ + devtools: boolean; + /** + * Whether to start the auto-updater. + */ + update: boolean; + /** + * Whether to start the app hidden. + */ + hidden: boolean; + /** + * Additional positional arguments found. + */ + positional: string[]; +} + +/** + * Electron creates the user data directory (with just an empty 'Dictionaries' directory...) + * as soon as the app path is set, so pick a random path in it that must exist if it's a + * real user data directory. + */ +function isRealUserDataDir(d: string): boolean { + return fs.existsSync(path.join(d, "IndexedDB")); +} + +function getUserDataPath(argv: ParsedArgs, protocolHandler: ProtocolHandler): string { + // check if we are passed a profile in the SSO callback url + const userDataPathInProtocol = protocolHandler.getProfileFromDeeplink(argv["_"]); + if (userDataPathInProtocol) { + return userDataPathInProtocol; + } + + if (argv["profile-dir"]) { + return argv["profile-dir"]; + } + + let newUserDataPath = process.env.ELEMENT_PROFILE_DIR ?? defaultUserDataDir; + if (argv["profile"]) { + newUserDataPath += "-" + argv["profile"]; + } + + const newUserDataPathExists = isRealUserDataDir(newUserDataPath); + let oldUserDataPath = path.join(app.getPath("appData"), app.getName().replace("Element", "Riot")); + if (argv["profile"]) { + oldUserDataPath += "-" + argv["profile"]; + } + + const oldUserDataPathExists = isRealUserDataDir(oldUserDataPath); + console.log(`${newUserDataPath} exists: ${newUserDataPathExists ? "yes" : "no"}`); + console.log(`${oldUserDataPath} exists: ${oldUserDataPathExists ? "yes" : "no"}`); + + if (!newUserDataPathExists && oldUserDataPathExists) { + console.log(`Using legacy user data path: ${oldUserDataPath}`); + return oldUserDataPath; + } + return newUserDataPath; +} + +/** + * Parses command line arguments and handles the `--help` flag. + * If `--help` is present, it prints usage information and exits the application. + * Must be called before Electron's userData is set. + */ +export function getArgs(protocolHandler: ProtocolHandler): Args { + const argv = minimist(process.argv, { + alias: { help: "h" }, + }); + + if (argv["help"]) { + console.log("Options:"); + console.log(" --profile-dir {path}: Path to where to store the profile."); + console.log( + ` --profile {name}: Name of alternate profile to use, allows for running multiple accounts.\n` + + ` Ignored if --profile-dir is specified.\n` + + ` The ELEMENT_PROFILE_DIR environment variable may be used to change the default profile path.\n` + + ` It is overridden by --profile-dir, but can be combined with --profile.`, + ); + console.log(" --devtools: Install and use react-devtools and react-perf."); + console.log( + ` --config: Path to the config.json file. May also be specified via the ELEMENT_DESKTOP_CONFIG_JSON environment variable.\n` + + ` Otherwise use the default user location '${defaultUserDataDir}'`, + ); + console.log(" --no-update: Disable automatic updating."); + console.log(" --hidden: Start the application hidden in the system tray."); + console.log(" --help: Displays this help message."); + console.log("And more such as --proxy, see: https://electronjs.org/docs/api/command-line-switches"); + app.exit(); + } + + let storageMode: Mode | undefined; + if ([Mode.Encrypted, Mode.ForcePlaintext, Mode.AllowPlaintext].includes(argv["storage-mode"])) { + storageMode = argv["storage-mode"]; + } + + return { + userDataPath: getUserDataPath(argv, protocolHandler), + localConfigPath: argv["config"] ?? process.env.ELEMENT_DESKTOP_CONFIG_JSON, + storageMode, + devtools: argv["devtools"] || false, + // Minimist parses `--no-`-prefixed arguments as booleans with value `false` rather than verbatim. + update: argv["update"] ?? true, + hidden: argv["hidden"] || false, + positional: argv["_"], + }; +} diff --git a/apps/desktop/src/asar.ts b/apps/desktop/src/asar.ts index ba9e9800da..0fc371b47b 100644 --- a/apps/desktop/src/asar.ts +++ b/apps/desktop/src/asar.ts @@ -6,11 +6,11 @@ Please see LICENSE files in the repository root for full details. */ import { fileURLToPath } from "node:url"; -import { dirname } from "node:path"; +import path from "node:path"; import { tryPaths } from "./utils.js"; -const __dirname = dirname(fileURLToPath(import.meta.url)); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); let asarPathPromise: Promise | undefined; // Get the webapp resource file path, memoizes result diff --git a/apps/desktop/src/auto-launch.ts b/apps/desktop/src/auto-launch.ts index 52c51bacc6..54271127c1 100644 --- a/apps/desktop/src/auto-launch.ts +++ b/apps/desktop/src/auto-launch.ts @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import BaseAutoLaunch from "auto-launch"; import Store from "./store.js"; +import { getConfig } from "./config.js"; export type AutoLaunchState = "enabled" | "minimised" | "disabled"; @@ -19,7 +20,7 @@ export class AutoLaunch extends BaseAutoLaunch { if (!AutoLaunch.internalInstance) { if (!Store.instance) throw new Error("Store not initialized"); AutoLaunch.internalInstance = new AutoLaunch({ - name: global.vectorConfig.brand || "Element", + name: getConfig().brand, isHidden: Store.instance.get("openAtLoginMinimised"), mac: { useLaunchAgent: true, diff --git a/apps/desktop/src/build-config.test.ts b/apps/desktop/src/build-config.test.ts new file mode 100644 index 0000000000..24db7f0d3f --- /dev/null +++ b/apps/desktop/src/build-config.test.ts @@ -0,0 +1,42 @@ +/* +Copyright 2026 Element Creations 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 { expect, describe, it, beforeEach, vi } from "vitest"; +import { fs as memfs, vol } from "memfs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { getBuildConfig } from "./build-config.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +vi.mock("node:fs", () => ({ default: memfs })); + +beforeEach(() => { + // Reset the state of the in-memory fs + vol.reset(); +}); + +describe("getBuildConfig", () => { + it("should read fields from package.json correctly", () => { + vol.fromJSON( + { + "../package.json": JSON.stringify({ + electron_appId: "app.id", + electron_protocol: "proto", + electron_windows_cert_sn: "subject.name", + }), + }, + __dirname, + ); + + const config = getBuildConfig(); + expect(config.appId).toBe("app.id"); + expect(config.protocol).toBe("proto"); + expect(config.windowsCertSubjectName).toBe("subject.name"); + }); +}); diff --git a/apps/desktop/src/build-config.ts b/apps/desktop/src/build-config.ts index b4d52865a3..ff04a79e24 100644 --- a/apps/desktop/src/build-config.ts +++ b/apps/desktop/src/build-config.ts @@ -5,12 +5,13 @@ 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 path, { dirname } from "node:path"; +import path from "node:path"; import { fileURLToPath } from "node:url"; +import { type JsonObject } from "shared-types"; -import { type JsonObject, loadJsonFile } from "./utils.js"; +import { loadJsonFile } from "./utils.js"; -const __dirname = dirname(fileURLToPath(import.meta.url)); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); let buildConfig: BuildConfig; diff --git a/apps/desktop/src/config.test.ts b/apps/desktop/src/config.test.ts new file mode 100644 index 0000000000..275b1f3e28 --- /dev/null +++ b/apps/desktop/src/config.test.ts @@ -0,0 +1,183 @@ +/* +Copyright 2026 Element Creations 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 { expect, describe, it, beforeEach, vi } from "vitest"; +import { fs as memfs, vol } from "memfs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { dialog } from "electron"; + +import { type ConfigOptions } from "./config.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +vi.mock("node:fs", () => ({ default: memfs })); +vi.mock("node:fs/promises", () => ({ default: memfs.promises })); + +vi.mock("electron", () => ({ + app: { + getPath: vi.fn().mockReturnValue("/Users/name/Library/Application Support/Element"), + whenReady: (): Promise => Promise.resolve(), + }, + dialog: { + showMessageBox: vi.fn(), + }, +})); + +beforeEach(() => { + // Reset the state of the in-memory fs + vol.reset(); +}); + +describe("loadConfig", () => { + let loadConfig: (localConfigPath: string | undefined) => Promise; + + beforeEach(async () => { + vol.fromJSON( + { + "../webapp.asar/config.json": JSON.stringify({ + web_base_url: "https://chat.org.com", + default_hs_url: "https://matrix.org.com", + }), + }, + __dirname, + ); + + vi.resetModules(); + ({ loadConfig } = await import("./config.js")); + }); + + it("should ignore localConfigPath if does not exist", async () => { + const config = await loadConfig("/invalid-path/custom-config.json"); + expect(config.brand).toBe("Element"); + expect(config.web_base_url).toBe("https://chat.org.com"); + expect(config.default_hs_url).toBe("https://matrix.org.com"); + }); + + it("should read localConfigPath if exists", async () => { + vol.fromJSON({ + "/home/custom-config.json": JSON.stringify({ + brand: "foobar", + }), + }); + + const config = await loadConfig("/home/custom-config.json"); + expect(config.brand).toBe("foobar"); + }); + + it("should load default local config if exists", async () => { + vol.fromJSON({ + "/Users/name/Library/Application Support/Element/config.json": JSON.stringify({ + brand: "foobar", + }), + }); + + const config = await loadConfig(undefined); + expect(config.brand).toBe("foobar"); + }); + + it("should apply defaults to any missing fields", async () => { + vol.fromJSON({ + "/home/custom-config.json": JSON.stringify({ + brand: "foobar", + }), + }); + + const config = await loadConfig("/home/custom-config.json"); + expect(config.help_url).toBe("https://element.io/help"); + expect(config.web_base_url).toBe("https://chat.org.com"); + }); + + it("should support all config files missing", async () => { + vol.reset(); + vol.fromJSON( + { + "../webapp.asar/version": "v1.2.3", + }, + __dirname, + ); + + const config = await loadConfig(undefined); + expect(config.help_url).toBe("https://element.io/help"); + expect(config.web_base_url).toBe("https://app.element.io/"); + }); + + it("should handle key conflicts around default homeserver config", async () => { + vol.fromJSON({ + "/home/custom-config.json": JSON.stringify({ + default_server_name: "other-org.com", + }), + }); + + const config = await loadConfig("/home/custom-config.json"); + expect(config.default_server_name).toBe("other-org.com"); + expect(config.default_hs_url).toBeUndefined(); + expect(config.default_server_config).toBeUndefined(); + }); + + it("should map module paths correctly", async () => { + vol.fromJSON( + { + "../webapp.asar/config.json": JSON.stringify({ + web_base_url: "https://chat.org.com", + default_hs_url: "https://matrix.org.com", + modules: ["/modules/banner", "module2"], + }), + }, + __dirname, + ); + + const config = await loadConfig("/home/custom-config.json"); + expect(config.help_url).toBe("https://element.io/help"); + expect(config.web_base_url).toBe("https://chat.org.com"); + expect(config.modules).toStrictEqual(["/webapp/modules/banner", "module2"]); + }); + + it("should show a dialog when encountering a SyntaxError", async () => { + vol.fromJSON({ + "/home/custom-config.json": "NOT_JSON", + }); + + await loadConfig("/home/custom-config.json"); + expect(dialog.showMessageBox).toHaveBeenCalledWith({ + detail: "Unexpected token 'N', \"NOT_JSON\" is not valid JSON", + message: + "Your custom Element configuration contains invalid JSON. Please correct the problem and reopen Element.", + title: "Your Element is misconfigured", + type: "error", + }); + }); +}); + +describe("getConfig", () => { + let loadConfig: (localConfigPath: string | undefined) => Promise; + let getConfig: () => ConfigOptions; + + beforeEach(async () => { + vol.fromJSON( + { + "../webapp.asar/config.json": JSON.stringify({ + web_base_url: "https://chat.org.com", + }), + }, + __dirname, + ); + + vi.resetModules(); + ({ loadConfig, getConfig } = await import("./config.js")); + }); + + it("should return undefined if loadConfig has not been called", () => { + expect(getConfig()).toBeUndefined(); + }); + + it("should return the config once it is loaded", async () => { + const config = await loadConfig(undefined); + expect(config.web_base_url).toBe("https://chat.org.com"); + expect(config).toStrictEqual(getConfig()); + }); +}); diff --git a/apps/desktop/src/config.ts b/apps/desktop/src/config.ts index f2415738d9..09d3446300 100644 --- a/apps/desktop/src/config.ts +++ b/apps/desktop/src/config.ts @@ -5,6 +5,116 @@ 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. */ -export function getBrand(): string { - return global.vectorConfig.brand || "Element"; +import { app, dialog } from "electron"; +import path from "node:path"; +import { type ResolveDefaults, type DesktopConfigJson, type JsonDocument } from "shared-types"; + +import { getAsarPath } from "./asar.js"; +import { loadJsonFile } from "./utils.js"; + +export type ConfigOptions = ResolveDefaults; + +const ConfigFilename = "config.json"; + +let config: ConfigOptions; + +const homeserverProps = ["default_is_url", "default_hs_url", "default_server_name", "default_server_config"] as const; + +function loadLocalConfigFile(location: string | undefined): JsonDocument { + if (location) { + console.log("Loading local config: " + location); + return loadJsonFile(location); + } else { + const configDir = app.getPath("userData"); + console.log(`Loading local config: ${path.join(configDir, ConfigFilename)}`); + return loadJsonFile(configDir, ConfigFilename); + } +} + +const DEFAULTS = { + brand: "Element", + help_url: "https://element.io/help", + web_base_url: "https://app.element.io/", +} satisfies DesktopConfigJson; + +function applyDefaults(conf: DesktopConfigJson): asserts conf is ConfigOptions { + for (const k in DEFAULTS) { + const key = k as keyof typeof DEFAULTS; + conf[key] ||= DEFAULTS[key]; + } +} + +let loadConfigPromise: Promise | undefined; +// Loads the config from asar, and applies a config.json from userData atop if one exists +// Writes config to `global.vectorConfig`. Idempotent, returns the same promise on subsequent calls. +export function loadConfig(localConfigPath: string | undefined): Promise { + if (loadConfigPromise) return loadConfigPromise; + + async function actuallyLoadConfig(): Promise { + const asarPath = await getAsarPath(); + + try { + console.log(`Loading app config: ${path.join(asarPath, ConfigFilename)}`); + // XXX: we trust that we built the package with a sane config, but should use something like zod here in future + const loadedConfig = loadJsonFile(asarPath, ConfigFilename) as unknown as DesktopConfigJson; + applyDefaults(loadedConfig); + config = loadedConfig; + } catch { + // it would be nice to check the error code here and bail if the config + // is unparsable, but we get MODULE_NOT_FOUND in the case of a missing + // file or invalid json, so node is just very unhelpful. + // Continue with the defaults (ie. an empty config) + config = { ...DEFAULTS }; + } + + try { + // Load local config and use it to override values from the one baked with the build + const localConfig = loadLocalConfigFile(localConfigPath); + + // If the local config has a homeserver defined, don't use the homeserver from the build + // config. This is to avoid a problem where Riot thinks there are multiple homeservers + // defined, and panics as a result. + if (Object.keys(localConfig).some((k) => homeserverProps.includes(k))) { + for (const key of homeserverProps) { + delete config[key]; + } + } + + config = Object.assign(config, localConfig); + } catch (e) { + if (e instanceof SyntaxError) { + await app.whenReady(); + void dialog.showMessageBox({ + type: "error", + title: `Your ${config.brand} is misconfigured`, + message: + `Your custom ${config.brand} configuration contains invalid JSON. ` + + `Please correct the problem and reopen ${config.brand}.`, + detail: e.message || "", + }); + } + + // Could not load local config, this is expected in most cases. + } + + // Tweak modules paths as they assume the root is at the same level as webapp, but for `vector://vector/webapp` it is not. + if (Array.isArray(config.modules)) { + config.modules = config.modules.map((m) => { + if (m.startsWith("/")) { + return "/webapp" + m; + } + return m; + }); + } + + // Apply defaults again in case the local config had an explicit null/undefined value for required keys. + applyDefaults(config); + return config; + } + loadConfigPromise = actuallyLoadConfig(); + return loadConfigPromise; +} + +export function getConfig(): ConfigOptions { + return config; } diff --git a/apps/desktop/src/electron-main.ts b/apps/desktop/src/electron-main.ts index 372c6fad24..7bb269baca 100644 --- a/apps/desktop/src/electron-main.ts +++ b/apps/desktop/src/electron-main.ts @@ -22,13 +22,10 @@ import { protocol, desktopCapturer, } from "electron"; -// eslint-disable-next-line n/file-extension-in-import import * as Sentry from "@sentry/electron/main"; -import path, { dirname } from "node:path"; +import path from "node:path"; import windowStateKeeper from "electron-window-state"; -import fs from "node:fs"; import { URL, fileURLToPath } from "node:url"; -import minimist from "minimist"; import "./ipc.js"; import "./seshat.js"; @@ -43,171 +40,31 @@ import ProtocolHandler from "./protocol.js"; import { _t, AppLocalization } from "./language-helper.js"; import { setDisplayMediaCallback } from "./displayMediaCallback.js"; import { setupMacosTitleBar } from "./macos-titlebar.js"; -import { type Json, loadJsonFile } from "./utils.js"; import { setupMediaAuth } from "./media-auth.js"; +import { type RendererRecovery, setupRendererRecovery } from "./renderer-recovery.js"; import { getBuildConfig } from "./build-config.js"; import { getAsarPath } from "./asar.js"; import { getIconPath } from "./icon.js"; +import { getArgs } from "./args.js"; +import { type ConfigOptions, loadConfig } from "./config.js"; -const __dirname = dirname(fileURLToPath(import.meta.url)); - -const argv = minimist(process.argv, { - alias: { help: "h" }, -}); - -if (argv["help"]) { - console.log("Options:"); - console.log(" --profile-dir {path}: Path to where to store the profile."); - console.log( - ` --profile {name}: Name of alternate profile to use, allows for running multiple accounts.\n` + - ` Ignored if --profile-dir is specified.\n` + - ` The ELEMENT_PROFILE_DIR environment variable may be used to change the default profile path.\n` + - ` It is overridden by --profile-dir, but can be combined with --profile.`, - ); - console.log(" --devtools: Install and use react-devtools and react-perf."); - console.log( - ` --config: Path to the config.json file. May also be specified via the ELEMENT_DESKTOP_CONFIG_JSON environment variable.\n` + - ` Otherwise use the default user location '${app.getPath("userData")}'`, - ); - console.log(" --no-update: Disable automatic updating."); - console.log(" --hidden: Start the application hidden in the system tray."); - console.log(" --help: Displays this help message."); - console.log("And more such as --proxy, see: https://electronjs.org/docs/api/command-line-switches"); - app.exit(); -} - -const LocalConfigLocation = process.env.ELEMENT_DESKTOP_CONFIG_JSON ?? argv["config"]; -const LocalConfigFilename = "config.json"; - -// Electron creates the user data directory (with just an empty 'Dictionaries' directory...) -// as soon as the app path is set, so pick a random path in it that must exist if it's a -// real user data directory. -function isRealUserDataDir(d: string): boolean { - return fs.existsSync(path.join(d, "IndexedDB")); -} +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const buildConfig = getBuildConfig(); const protocolHandler = new ProtocolHandler(buildConfig.protocol); +const args = getArgs(protocolHandler); -// check if we are passed a profile in the SSO callback url -let userDataPath: string; +app.setPath("userData", args.userDataPath); -const userDataPathInProtocol = protocolHandler.getProfileFromDeeplink(argv["_"]); -if (userDataPathInProtocol) { - userDataPath = userDataPathInProtocol; -} else if (argv["profile-dir"]) { - userDataPath = argv["profile-dir"]; -} else { - let newUserDataPath = process.env.ELEMENT_PROFILE_DIR ?? app.getPath("userData"); - if (argv["profile"]) { - newUserDataPath += "-" + argv["profile"]; - } - const newUserDataPathExists = isRealUserDataDir(newUserDataPath); - let oldUserDataPath = path.join(app.getPath("appData"), app.getName().replace("Element", "Riot")); - if (argv["profile"]) { - oldUserDataPath += "-" + argv["profile"]; - } - - const oldUserDataPathExists = isRealUserDataDir(oldUserDataPath); - console.log(newUserDataPath + " exists: " + (newUserDataPathExists ? "yes" : "no")); - console.log(oldUserDataPath + " exists: " + (oldUserDataPathExists ? "yes" : "no")); - if (!newUserDataPathExists && oldUserDataPathExists) { - console.log("Using legacy user data path: " + oldUserDataPath); - userDataPath = oldUserDataPath; - } else { - userDataPath = newUserDataPath; - } -} -app.setPath("userData", userDataPath); - -const homeserverProps = ["default_is_url", "default_hs_url", "default_server_name", "default_server_config"] as const; - -function loadLocalConfigFile(): Json { - if (LocalConfigLocation) { - console.log("Loading local config: " + LocalConfigLocation); - return loadJsonFile(LocalConfigLocation); - } else { - const configDir = app.getPath("userData"); - console.log(`Loading local config: ${path.join(configDir, LocalConfigFilename)}`); - return loadJsonFile(configDir, LocalConfigFilename); - } -} - -let loadConfigPromise: Promise | undefined; -// Loads the config from asar, and applies a config.json from userData atop if one exists -// Writes config to `global.vectorConfig`. Idempotent, returns the same promise on subsequent calls. -function loadConfig(): Promise { - if (loadConfigPromise) return loadConfigPromise; - - async function actuallyLoadConfig(): Promise { - const asarPath = await getAsarPath(); - - try { - console.log(`Loading app config: ${path.join(asarPath, LocalConfigFilename)}`); - global.vectorConfig = loadJsonFile(asarPath, LocalConfigFilename); - } catch { - // it would be nice to check the error code here and bail if the config - // is unparsable, but we get MODULE_NOT_FOUND in the case of a missing - // file or invalid json, so node is just very unhelpful. - // Continue with the defaults (ie. an empty config) - global.vectorConfig = {}; - } - - try { - // Load local config and use it to override values from the one baked with the build - const localConfig = loadLocalConfigFile(); - - // If the local config has a homeserver defined, don't use the homeserver from the build - // config. This is to avoid a problem where Riot thinks there are multiple homeservers - // defined, and panics as a result. - if (Object.keys(localConfig).find((k) => homeserverProps.includes(k))) { - // Rip out all the homeserver options from the vector config - global.vectorConfig = Object.keys(global.vectorConfig) - .filter((k) => !homeserverProps.includes(k)) - .reduce( - (obj, key) => { - obj[key] = global.vectorConfig[key]; - return obj; - }, - {} as Omit, keyof typeof homeserverProps>, - ); - } - - global.vectorConfig = Object.assign(global.vectorConfig, localConfig); - } catch (e) { - if (e instanceof SyntaxError) { - await app.whenReady(); - void dialog.showMessageBox({ - type: "error", - title: `Your ${global.vectorConfig.brand || "Element"} is misconfigured`, - message: - `Your custom ${global.vectorConfig.brand || "Element"} configuration contains invalid JSON. ` + - `Please correct the problem and reopen ${global.vectorConfig.brand || "Element"}.`, - detail: e.message || "", - }); - } - - // Could not load local config, this is expected in most cases. - } - - // Tweak modules paths as they assume the root is at the same level as webapp, but for `vector://vector/webapp` it is not. - if (Array.isArray(global.vectorConfig.modules)) { - global.vectorConfig.modules = global.vectorConfig.modules.map((m) => { - if (m.startsWith("/")) { - return "/webapp" + m; - } - return m; - }); - } - } - loadConfigPromise = actuallyLoadConfig(); - return loadConfigPromise; -} +// Renderer crash auto-recovery for the main window (element-web#32222). Held at module scope so the +// dock `activate` / `second-instance` relaunch handlers can route a crashed renderer through the same +// capped recovery rather than reloading inline (which would re-arm an already-given-up crash loop). +let rendererRecovery: RendererRecovery | undefined; // Configure Electron Sentry and crashReporter using sentry.dsn in config.json if one is present. async function configureSentry(): Promise { - await loadConfig(); - const { dsn, environment } = global.vectorConfig.sentry || {}; + const config = await loadConfig(args.localConfigPath); + const { dsn, environment } = config.sentry || {}; if (dsn) { console.log(`Enabling Sentry with dsn=${dsn} environment=${environment}`); Sentry.init({ @@ -251,9 +108,6 @@ if (!gotLock) { app.exit(); } -// do this after we know we are the primary instance of the app -protocolHandler.initialise(userDataPath); - // Register the scheme the app is served from as 'standard' // which allows things like relative URLs and IndexedDB to // work. @@ -284,7 +138,7 @@ app.enableSandbox(); // We disable media controls here. We do this because calls use audio and video elements and they sometimes capture the media keys. See https://github.com/vector-im/element-web/issues/15704 app.commandLine.appendSwitch("disable-features", "HardwareMediaKeyHandling,MediaSessionService"); -const store = Store.initialize(argv["storage-mode"]); // must be called before any async actions +const store = Store.initialize(args.storageMode); // must be called before any async actions // Disable hardware acceleration if the setting has been set. if (store.get("disableHardwareAcceleration")) { @@ -296,10 +150,11 @@ app.on("ready", async () => { console.debug("Reached Electron ready state"); let asarPath: string; + let config: ConfigOptions; try { asarPath = await getAsarPath(); - await loadConfig(); + config = await loadConfig(args.localConfigPath); } catch (e) { console.log("App setup failed: exiting", e); process.exit(1); @@ -310,7 +165,7 @@ app.on("ready", async () => { return; } - if (argv["devtools"]) { + if (args.devtools) { try { const { installExtension, REACT_DEVELOPER_TOOLS } = await import("electron-devtools-installer"); installExtension(REACT_DEVELOPER_TOOLS) @@ -373,11 +228,10 @@ app.on("ready", async () => { }); }); - // Minimist parses `--no-`-prefixed arguments as booleans with value `false` rather than verbatim. - if (argv["update"] === false) { + if (!args.update) { console.log("Auto update disabled via command line flag"); - } else if (global.vectorConfig["update_base_url"]) { - void updater.start(global.vectorConfig["update_base_url"]); + } else if (config.update_base_url) { + void updater.start(config.update_base_url); } else { console.log("No update_base_url is defined: auto update is disabled"); } @@ -401,7 +255,7 @@ app.on("ready", async () => { backgroundColor: "#fff", titleBarStyle: process.platform === "darwin" ? "hidden" : "default", - trafficLightPosition: { x: 9, y: 8 }, + trafficLightPosition: { x: 12, y: 8 }, icon: await getIconPath(), show: false, @@ -430,7 +284,11 @@ app.on("ready", async () => { app.exit(1); } - void global.mainWindow.loadURL("vector://vector/webapp/"); + // do this after we know we are the primary instance of the app + const hasDeeplink = protocolHandler.initialise(args); + if (!hasDeeplink) { + void global.mainWindow.loadURL("vector://vector/webapp/"); + } if (process.platform === "darwin") { setupMacosTitleBar(global.mainWindow); @@ -447,7 +305,7 @@ app.on("ready", async () => { if (!global.mainWindow) return; mainWindowState.manage(global.mainWindow); - if (!argv["hidden"]) { + if (!args.hidden) { global.mainWindow.show(); } else { // hide here explicitly because window manage above sometimes shows it @@ -474,7 +332,7 @@ app.on("ready", async () => { buttons: [ _t("action|cancel"), _t("action|close_brand", { - brand: global.vectorConfig.brand || "Element", + brand: config.brand, }), ], message: _t("confirm_quit"), @@ -524,6 +382,11 @@ app.on("ready", async () => { webContentsHandler(global.mainWindow.webContents); + // Auto-recover from an upstream renderer/GPU-process crash (white screen, element-web#32222). This + // is a MITIGATION of an upstream Electron/Chromium defect, not a root-cause fix — without it a dead + // renderer stays a permanent blank window the user can only escape by killing the whole app. + rendererRecovery = setupRendererRecovery(global.mainWindow); + session.defaultSession.setDisplayMediaRequestHandler( (_, callback) => { if (process.env.XDG_SESSION_TYPE === "wayland") { @@ -532,11 +395,13 @@ app.on("ready", async () => { desktopCapturer .getSources({ types: ["screen", "window"] }) .then((sources) => { + // oxlint-disable-next-line promise/no-callback-in-promise callback({ video: sources[0] }); }) .catch((err) => { // If the user cancels the dialog an error occurs "Failed to get sources" console.error("Wayland: failed to get user-selected source:", err); + // oxlint-disable-next-line promise/no-callback-in-promise callback({ video: { id: "", name: "" } }); // The promise does not return if no dummy is passed here as source }); } else { @@ -555,6 +420,11 @@ app.on("window-all-closed", () => { }); app.on("activate", () => { + // If the renderer crashed while the window was hidden (element-web#32222), reload it before showing + // so the user sees the UI rather than the white screen. Routed through the capped recovery (rather + // than an inline reload) so a relaunch can't re-arm a crash loop we've already given up on; it is a + // no-op when the renderer is healthy. + rendererRecovery?.recoverIfCrashed(); global.mainWindow?.show(); }); @@ -572,6 +442,10 @@ app.on("second-instance", (ev, commandLine, workingDirectory) => { // Someone tried to run a second instance, we should focus our window. if (global.mainWindow) { + // If the renderer crashed (element-web#32222), reload before surfacing the window so the user is + // brought to a working UI rather than a white screen. Routed through the capped recovery so a + // relaunch can't re-arm a crash loop we've already given up on; a no-op for a healthy window. + rendererRecovery?.recoverIfCrashed(); if (!global.mainWindow.isVisible()) global.mainWindow.show(); if (global.mainWindow.isMinimized()) global.mainWindow.restore(); global.mainWindow.focus(); diff --git a/apps/desktop/src/i18n/strings/en_EN.json b/apps/desktop/src/i18n/strings/en_EN.json index 88c584a00c..c4574fd130 100644 --- a/apps/desktop/src/i18n/strings/en_EN.json +++ b/apps/desktop/src/i18n/strings/en_EN.json @@ -27,6 +27,10 @@ "yes": "Yes" }, "confirm_quit": "Are you sure you want to quit?", + "download": { + "unable_to_open_description": "The file could not be opened. It may have been moved or deleted.", + "unable_to_open_title": "Unable to open file" + }, "edit_menu": { "speech": "Speech", "speech_start_speaking": "Start Speaking", @@ -53,6 +57,11 @@ "services": "Services", "unhide": "Unhide" }, + "renderer_crash": { + "detail": "Quit and reopen the app to continue. If this keeps happening, restarting your computer or clearing the app's cache may help.", + "message": "%(brand)s recovered from a problem several times but it keeps happening, so it has stopped trying.", + "title": "%(brand)s keeps crashing" + }, "right_click_menu": { "add_to_dictionary": "Add to dictionary", "copy_email": "Copy email address", @@ -63,6 +72,10 @@ "save_image_as_error_description": "The image failed to save", "save_image_as_error_title": "Failed to save image" }, + "save_dialog": { + "all_files": "All Files", + "named_file_type": "%(extension)s File" + }, "store": { "error": { "backend_changed": "Clear data and reload?", diff --git a/apps/desktop/src/i18n/strings/et.json b/apps/desktop/src/i18n/strings/et.json index 97433700f3..e568799d7b 100644 --- a/apps/desktop/src/i18n/strings/et.json +++ b/apps/desktop/src/i18n/strings/et.json @@ -27,6 +27,10 @@ "yes": "Jah" }, "confirm_quit": "Kas sa kindlasti soovid rakendusest väljuda?", + "download": { + "unable_to_open_description": "Faili avamine ei õnnestunud. See võib olla tõstetud mujale või kustutatud.", + "unable_to_open_title": "Faili avamine ei õnnestu" + }, "edit_menu": { "speech": "Kõne", "speech_start_speaking": "Alusta rääkimist", @@ -53,6 +57,11 @@ "services": "Teenused", "unhide": "Näita uuesti" }, + "renderer_crash": { + "detail": "Jätkamiseks sulge rakendus ja ava see uuesti. Kui probleem kordub, võib aidata arvuti uuesti käivitamine või rakenduse vahemälu tühjendamine.", + "message": "%(brand)s on mitu korda probleemist taastunud, kuid viga kordub ikka ja jälle ning rakendus on edasise proovimise lõpetanud.", + "title": "%(brand)s jookseb ühtevalu kokku" + }, "right_click_menu": { "add_to_dictionary": "Lisa sõnastikku", "copy_email": "Kopeeri e-posti aadress", diff --git a/apps/desktop/src/i18n/strings/fr.json b/apps/desktop/src/i18n/strings/fr.json index 9c3babb1f5..47e9ccf949 100644 --- a/apps/desktop/src/i18n/strings/fr.json +++ b/apps/desktop/src/i18n/strings/fr.json @@ -27,6 +27,10 @@ "yes": "Oui" }, "confirm_quit": "Êtes-vous sûr de vouloir quitter ?", + "download": { + "unable_to_open_description": "Impossible d'ouvrir le fichier. Il a peut-être été déplacé ou supprimé.", + "unable_to_open_title": "Impossible d'ouvrir le fichier" + }, "edit_menu": { "speech": "Dictée", "speech_start_speaking": "Commencer la dictée", @@ -53,6 +57,11 @@ "services": "Services", "unhide": "Dé-masquer" }, + "renderer_crash": { + "detail": "Redémarrer l'application pour continuer. Si le problème persiste, redémarrer votre ordinateur ou vider le cache de l'application peut résoudre le problème.", + "message": "%(brand)s s'est remis d'un problème à plusieurs reprises, mais celui-ci continue de se produire, il a donc cessé d'essayer.", + "title": "%(brand)s s'arrête brutalement de façon répétée" + }, "right_click_menu": { "add_to_dictionary": "Ajouter au dictionnaire", "copy_email": "Copier l’adresse e-mail", diff --git a/apps/desktop/src/i18n/strings/hr.json b/apps/desktop/src/i18n/strings/hr.json index 3d09d16596..39aaa29221 100644 --- a/apps/desktop/src/i18n/strings/hr.json +++ b/apps/desktop/src/i18n/strings/hr.json @@ -54,6 +54,11 @@ "services": "Usluge", "unhide": "Otkrij" }, + "renderer_crash": { + "detail": "Zatvorite i ponovno otvorite aplikaciju kako biste nastavili. Ako se to nastavi događati, moglo bi pomoći ponovno pokretanje računala ili brisanje predmemorije aplikacije.", + "message": "%(brand)s se nekoliko puta oporavio od problema, ali se problem i dalje javlja, pa je prestao pokušavati.", + "title": "%(brand)s se stalno ruši" + }, "right_click_menu": { "add_to_dictionary": "Dodaj u rječnik", "copy_email": "Kopiraj e-adresu", diff --git a/apps/desktop/src/i18n/strings/pl.json b/apps/desktop/src/i18n/strings/pl.json index fc209c9e71..5b04975cf8 100644 --- a/apps/desktop/src/i18n/strings/pl.json +++ b/apps/desktop/src/i18n/strings/pl.json @@ -54,6 +54,11 @@ "services": "Usługi", "unhide": "Odkryj" }, + "renderer_crash": { + "detail": "Zamknij i uruchom ponownie aplikację, żeby kontynuować. Jeśli problem nie ustępuje, spróbuj wyczyścić pamięć podręczną i zresetować komputer.", + "message": "%(brand)s przestał próbować naprawić problem, który udało się ominąć lecz ciągle się ponawia.", + "title": "%(brand)s nie może przestać się zawieszać" + }, "right_click_menu": { "add_to_dictionary": "Dodaj do słownika", "copy_email": "Kopiuj adres e-mail", diff --git a/apps/desktop/src/i18n/strings/uk.json b/apps/desktop/src/i18n/strings/uk.json index 2b29b8c03b..306148204a 100644 --- a/apps/desktop/src/i18n/strings/uk.json +++ b/apps/desktop/src/i18n/strings/uk.json @@ -27,6 +27,10 @@ "yes": "Так" }, "confirm_quit": "Ви впевнені, що хочете вийти?", + "download": { + "unable_to_open_description": "Не вдалося відкрити файл. Можливо, його було переміщено або видалено.", + "unable_to_open_title": "Неможливо відкрити файл" + }, "edit_menu": { "speech": "Мовлення", "speech_start_speaking": "Почати говорити", @@ -54,6 +58,11 @@ "services": "Служби", "unhide": "Показати" }, + "renderer_crash": { + "detail": "Закрийте та знову запустіть застосунок, щоб продовжити роботу. Якщо ця проблема повторюється, може допомогти перезапуск комп’ютера або очищення кешу застосунку.", + "message": "%(brand)s кілька разів відновлювався після проблеми, але вона постійно виникає, тому спроби припинилися.", + "title": "Збої %(brand)s продовжуються" + }, "right_click_menu": { "add_to_dictionary": "Додати до словника", "copy_email": "Копіювати адресу е-пошти", diff --git a/apps/desktop/src/i18n/strings/zh_Hans.json b/apps/desktop/src/i18n/strings/zh_Hans.json index d9b6be5832..14974c3fec 100644 --- a/apps/desktop/src/i18n/strings/zh_Hans.json +++ b/apps/desktop/src/i18n/strings/zh_Hans.json @@ -27,6 +27,10 @@ "yes": "是" }, "confirm_quit": "你确定要退出吗?", + "download": { + "unable_to_open_description": "无法打开文件,它可能已被移动或删除。", + "unable_to_open_title": "无法打开文件" + }, "edit_menu": { "speech": "讲话", "speech_start_speaking": "开始讲话", @@ -52,6 +56,11 @@ "services": "服务", "unhide": "显示" }, + "renderer_crash": { + "detail": "退出并重新打开 app 以继续。如果问题仍然存在,重启电脑或清除缓存或许有用。", + "message": "%(brand)s 已经多次从故障中恢复,但仍然反复发生,因此已停止尝试。", + "title": "%(brand)s 仍在持续崩溃" + }, "right_click_menu": { "add_to_dictionary": "添加到字典", "copy_email": "复制邮箱地址", @@ -62,6 +71,10 @@ "save_image_as_error_description": "图片保存失败", "save_image_as_error_title": "图片保存失败" }, + "save_dialog": { + "all_files": "所有文件", + "named_file_type": "%(extension)s 文件" + }, "store": { "error": { "backend_changed": "清除数据并重新加载?", diff --git a/apps/desktop/src/icon.test.ts b/apps/desktop/src/icon.test.ts new file mode 100644 index 0000000000..a8886be9b5 --- /dev/null +++ b/apps/desktop/src/icon.test.ts @@ -0,0 +1,44 @@ +/* +Copyright 2026 Element Creations 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 { expect, describe, it, beforeEach, vi } from "vitest"; +import { fs as memfs, vol } from "memfs"; + +import { getIconPath } from "./icon.js"; +import { fileURLToPath } from "node:url"; + +vi.mock("node:fs/promises", () => ({ default: memfs.promises })); + +beforeEach(() => { + // Reset the state of the in-memory fs + vol.reset(); +}); + +describe("getIconPath", () => { + beforeEach(() => { + vol.fromJSON( + { + "build/icon.png": "png", + "build/icon.ico": "ico", + }, + fileURLToPath(import.meta.resolve("../webapp")), + ); + }); + + it("should use .ico on Windows", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + await expect(getIconPath()).resolves.toEqual(fileURLToPath(import.meta.resolve("../build/icon.ico"))); + }); + it("should use .png on macOS", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + await expect(getIconPath()).resolves.toEqual(fileURLToPath(import.meta.resolve("../build/icon.png"))); + }); + it("should use .png on Linux", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + await expect(getIconPath()).resolves.toEqual(fileURLToPath(import.meta.resolve("../build/icon.png"))); + }); +}); diff --git a/apps/desktop/src/ipc.test.ts b/apps/desktop/src/ipc.test.ts new file mode 100644 index 0000000000..5b69168f75 --- /dev/null +++ b/apps/desktop/src/ipc.test.ts @@ -0,0 +1,36 @@ +/* +Copyright 2026 Element Creations 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 { expect, describe, it, vi } from "vitest"; +import { ipcMain, type IpcMainInvokeEvent } from "electron"; + +import { getConfig } from "./config.js"; + +vi.mock("electron", () => ({ + ipcMain: { + on: vi.fn(), + once: vi.fn(), + handle: vi.fn(), + }, +})); + +vi.mock("./config.js"); + +describe("getConfig", () => { + it("should call config.getConfig and return the value", async () => { + const config = { brand: "BRAND", help_url: "HELP_URL", web_base_url: "WEB_BASE_URL" }; + vi.mocked(getConfig).mockReturnValue(config); + + await import("./ipc.js"); + + const handler = vi.mocked(ipcMain.handle).mock.calls.find(([channel]) => channel === "getConfig")?.[1]; + expect(handler).toBeDefined(); + + expect(handler!(new Event("test") as unknown as IpcMainInvokeEvent)).toStrictEqual(config); + expect(getConfig).toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index c59f70eb82..229a525662 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -11,6 +11,7 @@ import IpcMainEvent = Electron.IpcMainEvent; import { randomArray } from "./utils.js"; import { getDisplayMediaCallback, setDisplayMediaCallback } from "./displayMediaCallback.js"; import Store, { clearDataAndRelaunch } from "./store.js"; +import { getConfig } from "./config.js"; let focusHandlerAttached = false; ipcMain.on("loudNotification", function (): void { @@ -143,7 +144,7 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) { })); break; case "callDisplayMediaCallback": - await getDisplayMediaCallback()?.({ video: args[0] }); + getDisplayMediaCallback()?.({ video: args[0] }); setDisplayMediaCallback(null); ret = null; break; @@ -217,7 +218,7 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) { }); }); -ipcMain.handle("getConfig", () => global.vectorConfig); +ipcMain.handle("getConfig", getConfig); const initialisePromiseWithResolvers = Promise.withResolvers(); export const initialisePromise = initialisePromiseWithResolvers.promise; diff --git a/apps/desktop/src/language-helper.ts b/apps/desktop/src/language-helper.ts index 23a859a902..1194810902 100644 --- a/apps/desktop/src/language-helper.ts +++ b/apps/desktop/src/language-helper.ts @@ -7,14 +7,14 @@ Please see LICENSE files in the repository root for full details. import counterpart from "counterpart"; import { type TranslationKey as TKey } from "matrix-web-i18n"; -import { dirname } from "node:path"; +import path from "node:path"; import { fileURLToPath } from "node:url"; import type EN from "./i18n/strings/en_EN.json"; import { loadJsonFile } from "./utils.js"; import type Store from "./store.js"; -const __dirname = dirname(fileURLToPath(import.meta.url)); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const FALLBACK_LOCALE = "en"; @@ -77,7 +77,6 @@ export class AppLocalization { if (store.has(AppLocalization.STORE_KEY)) { const locales = store.get(AppLocalization.STORE_KEY); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.setAppLocale(locales!); } @@ -113,7 +112,7 @@ export class AppLocalization { locales = [locales]; } - const loadedLocales = locales.filter((locale) => { + const chosenLocale = locales.find((locale) => { const translations = this.fetchTranslationJson(locale); if (translations !== null) { counterpart.registerTranslations(locale, translations); @@ -121,7 +120,7 @@ export class AppLocalization { return !!translations; }); - counterpart.setLocale(loadedLocales[0]); + counterpart.setLocale(chosenLocale!); this.store.set(AppLocalization.STORE_KEY, locales); this.resetLocalizedUI(); diff --git a/apps/desktop/src/macos-titlebar.test.ts b/apps/desktop/src/macos-titlebar.test.ts new file mode 100644 index 0000000000..c22ce2299d --- /dev/null +++ b/apps/desktop/src/macos-titlebar.test.ts @@ -0,0 +1,238 @@ +/* +Copyright 2026 Spencer Poisseroux +Copyright 2026 hayaksi1 + +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 { afterEach, describe, expect, it, vi, type Mock } from "vitest"; +import type { BrowserWindow } from "electron"; + +import { buildTitleBarCss, setupMacosTitleBar, TITLE_BAR_HEIGHT_PX } from "./macos-titlebar.js"; + +/** + * Extract the declaration block for the first rule whose selector list contains the given selector. + */ +function ruleBlock(css: string, selector: string): string { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const blockMatch = new RegExp(`[^{}]*${escaped}[^{}]*\\{([^}]*)\\}`).exec(css); + expect(blockMatch, `expected a rule block for "${selector}"`).not.toBeNull(); + return blockMatch![1]; +} + +describe("buildTitleBarCss", () => { + const css = buildTitleBarCss(); + + it("returns a non-empty CSS string", () => { + expect(typeof css).toBe("string"); + expect(css.length).toBeGreaterThan(0); + }); + + it("draws the title bar band at the designed height", () => { + const bar = ruleBlock(css, "body::before"); + expect(bar).toMatch(new RegExp(`height:\\s*${TITLE_BAR_HEIGHT_PX}px`)); + expect(bar).toMatch(/position:\s*fixed/); + }); + + it("styles the title bar with the canvas background and separator tokens", () => { + // Matches the design spec: bg/canvas/default fill with a 1px separator/primary hairline below. + const bar = ruleBlock(css, "body::before"); + expect(bar).toMatch(/background:\s*var\(--cpd-color-bg-canvas-default\b/); + expect(bar).toMatch(/border-bottom:\s*1px\s+solid\s+var\(--cpd-color-separator-primary\b/); + }); + + it("makes the title bar a drag handle", () => { + expect(ruleBlock(css, "body::before")).toMatch(/-webkit-app-region:\s*drag/); + }); + + it("pushes the app content below the title bar band", () => { + const body = ruleBlock(css, "body"); + expect(body).toMatch(new RegExp(`padding-top:\\s*${TITLE_BAR_HEIGHT_PX}px`)); + expect(body).toMatch(/box-sizing:\s*border-box/); + }); + + it("keeps the window draggable through an overlapping dialog panel", () => { + // Regression guard: a blanket `.mx_Dialog`/`.mx_Dialog_border` no-drag carves the panel + // (incl. the Glass border) out of the band, killing the drag where a centred dialog overlaps + // it. The panel must stay transparent to the drag calc so body::before shows through. + expect(css).not.toMatch(/(^|[^_-])\.mx_Dialog\s*\{[^}]*no-drag/); + expect(css).not.toMatch(/\.mx_Dialog_border\b[^{]*\{[^}]*no-drag/); + }); + + it("does not gate the drag region on a modal being open", () => { + // Regression guard: an earlier draft no-dragged the whole bar via the aria-hidden modal + // signal, making the window undraggable with e.g. the settings dialog open. + expect(css).not.toContain("aria-hidden"); + }); + + it("does not turn whole screens into drag regions (#34661)", () => { + // Regression guard: these screens cover the window, and the controls drawn on them keep the + // default app-region of `none` — so they never subtract from the region and their clicks + // become window drags instead. That left the device verification screen and the splash + // screen's sign-out button reachable only by keyboard. The band is the drag handle. + expect(css).not.toMatch(/\.mx_AuthPage[^{]*\{[^}]*-webkit-app-region:\s*drag/); + expect(css).not.toMatch(/\.mx_MatrixChat_splash[^{]*\{[^}]*-webkit-app-region:\s*drag/); + }); + + it("keeps floating portal overlays clickable within the band", () => { + // Compound menus/tooltips render in body-level portals and can open anywhere, incl. the band. + expect(css).toMatch(/\[data-radix-popper-content-wrapper\][^{]*\{[^}]*-webkit-app-region:\s*no-drag/); + }); + + it("no longer carves per-surface drag strips into the app chrome", () => { + // The dedicated bar replaces the old hacks; their reappearance would double up the offset. + expect(css).not.toContain(".mx_LeftPanel::before"); + expect(css).not.toContain(".mx_RoomView::before"); + expect(css).not.toContain(".mx_SpaceRoomView::before"); + expect(css).not.toContain(".mx_UserMenu"); + expect(css).not.toContain(".mx_SpacePanel"); + }); + + it("keeps the lightbox sender info clear of the traffic lights", () => { + expect(ruleBlock(css, ".mx_ImageView_info_wrapper")).toMatch( + new RegExp(`margin-top:\\s*${TITLE_BAR_HEIGHT_PX}px`), + ); + }); + + it("keeps the lightbox header a drag handle with interactive elements excluded", () => { + expect(ruleBlock(css, ".mx_ImageView_panel")).toMatch(/-webkit-app-region:\s*drag/); + expect(css).toMatch(/\.mx_ImageView_panel\s*>\s*\.mx_ImageView_toolbar\s*>\s*\*\s*\{[^}]*no-drag/); + }); + + it("keeps context menus excluded from the drag region (no-drag)", () => { + expect(ruleBlock(css, ".mx_ContextualMenu")).toMatch(/-webkit-app-region:\s*no-drag/); + }); + + it("keeps iframes excluded from the drag region (no-drag)", () => { + // iframes (e.g. recaptcha, widgets) must remain interactive. + expect(css).toMatch(/iframe\s*\{[^}]*-webkit-app-region:\s*no-drag/); + }); +}); + +describe("setupMacosTitleBar", () => { + /** Minimal `BrowserWindow` stand-in: the module only ever touches these members. */ + function mockWindow(): { + window: BrowserWindow; + windowHandlers: Map void>; + webContentsHandlers: Map void>; + insertCSS: Mock; + removeInsertedCSS: Mock; + isFullScreen: Mock; + } { + const windowHandlers = new Map void>(); + const webContentsHandlers = new Map void>(); + const insertCSS = vi.fn<(css: string) => Promise>().mockResolvedValue("css-key-1"); + const removeInsertedCSS = vi.fn<(key: string) => Promise>().mockResolvedValue(undefined); + const isFullScreen = vi.fn<() => boolean>().mockReturnValue(false); + + const window = { + on: vi.fn((event: string, handler: () => void) => { + windowHandlers.set(event, handler); + }), + isFullScreen, + webContents: { + on: vi.fn((event: string, handler: () => void) => { + webContentsHandlers.set(event, handler); + }), + insertCSS, + removeInsertedCSS, + }, + } as unknown as BrowserWindow; + + return { window, windowHandlers, webContentsHandlers, insertCSS, removeInsertedCSS, isFullScreen }; + } + + /** + * The listeners are `() => void` and start `applyStyling()` without awaiting it, so awaiting a handler's + * own return value would prove nothing. Yield to the macrotask queue instead, which drains the pending + * microtasks and lets that fire-and-forget promise settle before we assert on its effects. + */ + function flushStyling(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); + } + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does nothing on non-darwin platforms", () => { + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const { window, insertCSS } = mockWindow(); + + setupMacosTitleBar(window); + + expect(window.on).not.toHaveBeenCalled(); + expect(window.webContents.on).not.toHaveBeenCalled(); + expect(insertCSS).not.toHaveBeenCalled(); + }); + + it("registers the full-screen and load listeners on darwin", () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const { window } = mockWindow(); + + setupMacosTitleBar(window); + + expect(window.on).toHaveBeenCalledWith("enter-full-screen", expect.any(Function)); + expect(window.on).toHaveBeenCalledWith("leave-full-screen", expect.any(Function)); + expect(window.webContents.on).toHaveBeenCalledWith("did-finish-load", expect.any(Function)); + }); + + it("injects the title bar CSS once the page has loaded", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const { window, webContentsHandlers, insertCSS } = mockWindow(); + + setupMacosTitleBar(window); + webContentsHandlers.get("did-finish-load")!(); + await flushStyling(); + + expect(insertCSS).toHaveBeenCalledOnce(); + expect(insertCSS).toHaveBeenCalledWith(buildTitleBarCss()); + }); + + it("does not inject the CSS if the window loads while already full screen", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const { window, webContentsHandlers, insertCSS, isFullScreen } = mockWindow(); + isFullScreen.mockReturnValue(true); + + setupMacosTitleBar(window); + webContentsHandlers.get("did-finish-load")!(); + await flushStyling(); + + expect(insertCSS).not.toHaveBeenCalled(); + }); + + it("removes the injected CSS when entering full screen", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const { window, windowHandlers, webContentsHandlers, removeInsertedCSS } = mockWindow(); + + setupMacosTitleBar(window); + webContentsHandlers.get("did-finish-load")!(); + await flushStyling(); + windowHandlers.get("enter-full-screen")!(); + + expect(removeInsertedCSS).toHaveBeenCalledWith("css-key-1"); + }); + + it("does not attempt to remove the CSS if none was ever injected", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const { window, windowHandlers, removeInsertedCSS } = mockWindow(); + + setupMacosTitleBar(window); + windowHandlers.get("enter-full-screen")!(); + await flushStyling(); + + expect(removeInsertedCSS).not.toHaveBeenCalled(); + }); + + it("re-injects the CSS when leaving full screen", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const { window, windowHandlers, insertCSS } = mockWindow(); + + setupMacosTitleBar(window); + windowHandlers.get("leave-full-screen")!(); + await flushStyling(); + + expect(insertCSS).toHaveBeenCalledWith(buildTitleBarCss()); + }); +}); diff --git a/apps/desktop/src/macos-titlebar.ts b/apps/desktop/src/macos-titlebar.ts index b8b839d6ce..1864a389d7 100644 --- a/apps/desktop/src/macos-titlebar.ts +++ b/apps/desktop/src/macos-titlebar.ts @@ -7,69 +7,67 @@ Please see LICENSE files in the repository root for full details. import type { BrowserWindow } from "electron"; -export function setupMacosTitleBar(window: BrowserWindow): void { - if (process.platform !== "darwin") return; +/** + * Height of the styled title bar band, matching the design spec + * (https://www.figma.com/design/MAUsalKv7bRRNKlAAigtNd/Community-contributions?node-id=76-12996). + * The `trafficLightPosition` in `electron-main.ts` vertically centres the native window controls + * within this band — keep the two in sync. + */ +export const TITLE_BAR_HEIGHT_PX = 32; - let cssKey: string | undefined; +/** + * Build the CSS injected into the renderer to draw the macOS title bar band. + * + * `electron-main.ts` uses `titleBarStyle: "hidden"`, which keeps the native window frame, rounded + * corners and traffic lights but removes the native bar surface. This CSS paints that surface: a + * full-width strip at the top of the window in the canvas background colour with a hairline + * separator underneath, and pushes the app content below it. The strip is the window's drag + * handle. + * + * The drag region is built from `-webkit-app-region` rects only: elements keep the default value + * (`none`) and are ignored unless they explicitly set `drag`/`no-drag`. So the band stays draggable + * underneath any overlay whose surface is transparent to the calc (a dialog's backdrop, a menu's + * container), and an overlay panel that a user clicks (dialog panels, context menus, the lightbox + * chrome) sets `no-drag` so its rect is subtracted and it stays interactive. An element must never + * be both clickable and a drag handle. + * + * Extracted as a pure helper so the string contract can be unit-tested (see macos-titlebar.test.ts). + */ +export function buildTitleBarCss(): string { + return ` + /* Reserve a band at the top of the window for the title bar */ + body { + box-sizing: border-box; + height: 100%; + padding-top: ${TITLE_BAR_HEIGHT_PX}px !important; + } - async function applyStyling(): Promise { - cssKey = await window.webContents.insertCSS(` - /* Create margin of space for the traffic light buttons */ - .mx_UserMenu { - /* We zero the margin and use padding as we want to use it as a drag handle */ - margin-top: 0 !important; - margin-left: 0 !important; - padding-top: 32px !important; - padding-left: 20px !important; + /* The title bar itself: canvas background with a hairline separator below */ + body::before { + content: ""; + position: fixed; + top: 0; + left: 0; + right: 0; + height: ${TITLE_BAR_HEIGHT_PX}px; + box-sizing: border-box; + /* Fallback colours for pages loaded without the app themes (e.g. the error view) */ + background: var(--cpd-color-bg-canvas-default, #ffffff); + border-bottom: 1px solid var(--cpd-color-separator-primary, #e1e6ec); -webkit-app-region: drag; -webkit-user-select: none; } - /* Exclude the button from being a drag handle and not working */ - .mx_UserMenu > * { - -webkit-app-region: no-drag; + + /* Exclude floating menus and tooltips, which render in body-level portals */ + [data-radix-popper-content-wrapper] { + -webkit-app-region: no-drag; } - /* Maintain alignment of the toggle space panel button */ - .mx_SpacePanel_toggleCollapse { - /* 19px original top value, 32px margin-top above, 12px original margin-top value */ - top: calc(19px + 32px - 12px) !important; - } - /* Prevent the media lightbox sender info from clipping into the traffic light buttons */ + + /* The image lightbox covers the whole window, including the title bar band; keep its + sender info clear of the traffic lights, and let its header double as a drag handle */ .mx_ImageView_info_wrapper { - margin-top: 32px; + margin-top: ${TITLE_BAR_HEIGHT_PX}px; } - - /* Mark the splash screen as a drag handle */ - .mx_MatrixChat_splash { - -webkit-app-region: drag; - } - /* Exclude the splash buttons from being drag handles */ - .mx_MatrixChat_splashButtons { - -webkit-app-region: no-drag; - } - - /* Mark the background as a drag handle */ - .mx_AuthPage { - -webkit-app-region: drag; - } - /* Exclude the main content elements from being drag handles */ - .mx_AuthPage .mx_AuthPage_modalContent, - .mx_AuthPage .mx_AuthPage_modalBlur, - .mx_AuthPage .mx_AuthFooter > *, - .mx_AuthPage .mx_Dropdown_menu { - -webkit-app-region: no-drag; - } - - /* Mark the home page background as a drag handle */ - .mx_HomePage { - -webkit-app-region: drag; - } - /* Exclude interactive elements from being drag handles */ - .mx_HomePage .mx_HomePage_body, - .mx_HomePage .mx_HomePage_default_wrapper > * { - -webkit-app-region: no-drag; - } - - /* Mark the header as a drag handle */ .mx_ImageView_panel { -webkit-app-region: drag; } @@ -79,27 +77,8 @@ export function setupMacosTitleBar(window: BrowserWindow): void { .mx_ImageView_panel > .mx_ImageView_toolbar > * { -webkit-app-region: no-drag; } - - /* Mark the background as a drag handle only if no modal is open */ - .mx_MatrixChat_wrapper[aria-hidden="false"] .mx_RoomView_wrapper, - .mx_MatrixChat_wrapper[aria-hidden="false"] .mx_HomePage { - -webkit-app-region: drag; - } - /* Exclude content elements from being drag handles */ - .mx_SpaceRoomView_landing > *, - .mx_RoomPreviewBar, - .mx_RoomView_body, - .mx_AutoHideScrollbar, - .mx_RightPanel_ResizeWrapper, - .mx_RoomPreviewCard, - .mx_LeftPanel, - .mx_RoomView, - .mx_SpaceRoomView, - .mx_AccessibleButton, - .mx_Dialog { - -webkit-app-region: no-drag; - } - /* Exclude context menus and their backgrounds */ + + /* Exclude context menus and their backgrounds, which may open within the band */ .mx_ContextualMenu, .mx_ContextualMenu_background { -webkit-app-region: no-drag; } @@ -107,40 +86,16 @@ export function setupMacosTitleBar(window: BrowserWindow): void { iframe { -webkit-app-region: no-drag; } + `; +} - /* Add a bar above room header + left panel */ - - .mx_LeftPanel { - flex-direction: column; - } +export function setupMacosTitleBar(window: BrowserWindow): void { + if (process.platform !== "darwin") return; - .mx_LeftPanel::before { - content: ""; - height: 20px; - -webkit-app-region: drag; - } - - .mx_LeftPanel_newRoomList::before { - /* Aligned with the room header */ - height: 13px; - border-right: 1px solid var(--cpd-color-bg-subtle-primary); - } + let cssKey: string | undefined; - .mx_RoomView::before, - .mx_SpaceRoomView::before { - content: ""; - -webkit-app-region: drag; - } - - .mx_SpaceRoomView::before { - display: block; - height: 24px; - } - - .mx_RoomView::before { - height: 13px; - } - `); + async function applyStyling(): Promise { + cssKey = await window.webContents.insertCSS(buildTitleBarCss()); } window.on("enter-full-screen", () => { diff --git a/apps/desktop/src/preload.cts b/apps/desktop/src/preload.cts index 677f996397..8425b7b82e 100644 --- a/apps/desktop/src/preload.cts +++ b/apps/desktop/src/preload.cts @@ -8,7 +8,8 @@ Please see LICENSE files in the repository root for full details. // This file is compiled to CommonJS rather than ESM otherwise the browser chokes on the import statement. -import { ipcRenderer, contextBridge, IpcRendererEvent } from "electron"; +import { ipcRenderer, contextBridge, type IpcRendererEvent } from "electron"; +import type { ConfigOptions } from "./config.js" with { "resolution-mode": "import" }; // Expose only expected IPC wrapper APIs to the renderer process to avoid // handing out generalised messaging access. @@ -54,7 +55,7 @@ contextBridge.exposeInMainWorld("electron", { async initialise(): Promise<{ protocol: string; sessionId: string; - config: IConfigOptions; + config: ConfigOptions; supportedSettings: Record; /** * Do we need to render badge overlays for new notifications? diff --git a/apps/desktop/src/protocol.test.ts b/apps/desktop/src/protocol.test.ts index 396a59f065..2f85301623 100644 --- a/apps/desktop/src/protocol.test.ts +++ b/apps/desktop/src/protocol.test.ts @@ -7,6 +7,8 @@ Please see LICENSE files in the repository root for full details. import { expect, describe, it, beforeEach, vi } from "vitest"; import { fs as memfs, vol } from "memfs"; +import EventEmitter from "node:events"; +import { app } from "electron"; import ProtocolHandler from "./protocol.js"; @@ -15,34 +17,45 @@ const TEST_SESSION_ID = "test_session_id"; const USER_DATA_DIR = "/Users/name/Library/Application Support/Element"; vi.mock("node:fs", () => ({ default: memfs })); -vi.mock("electron", () => ({ - app: { - getPath: vi.fn().mockReturnValue("/Users/name/Library/Application Support/Element"), - on: vi.fn(), - }, - ipcMain: { - handle: vi.fn(), - }, -})); +vi.mock("electron", () => { + const emitter = new EventEmitter(); + + return { + app: { + isPackaged: true, + getPath: vi.fn().mockReturnValue("/Users/name/Library/Application Support/Element"), + getAppPath: vi.fn().mockReturnValue("/bin/element-desktop"), + setAsDefaultProtocolClient: vi.fn(), + on: emitter.on.bind(emitter), + emit: emitter.emit.bind(emitter), + removeAllListeners: emitter.removeAllListeners.bind(emitter), + }, + ipcMain: { + handle: vi.fn(), + }, + }; +}); beforeEach(() => { // Reset the state of the in-memory fs vol.reset(); + // Clear the event emitter + app.removeAllListeners(); }); describe("ProtocolHandler", () => { + beforeEach(() => { + vol.fromJSON( + { + "./sso-sessions.json": JSON.stringify({ [TEST_SESSION_ID]: USER_DATA_DIR }), + }, + USER_DATA_DIR, + ); + }); + describe("getProfileFromDeeplink", () => { const handler = new ProtocolHandler(TEST_PROTOCOL); - beforeEach(() => { - vol.fromJSON( - { - "./sso-sessions.json": JSON.stringify({ [TEST_SESSION_ID]: USER_DATA_DIR }), - }, - USER_DATA_DIR, - ); - }); - it("should handle legacy SSO URIs", () => { expect( handler.getProfileFromDeeplink([ @@ -84,4 +97,77 @@ describe("ProtocolHandler", () => { expect(handler.getProfileFromDeeplink(["Element.app", `test.unrelated:/vector/webapp/`])).toBeUndefined(); }); }); + + it.each(["darwin", "linux", "win32"] as const)("should handle deeplink on %s", (platform) => { + vi.spyOn(process, "platform", "get").mockReturnValue(platform); + vi.stubGlobal("mainWindow", { + loadURL: vi.fn(), + }); + + const handler = new ProtocolHandler(TEST_PROTOCOL); + expect(handler).toBeTruthy(); + + const incomingUri = "test.proto:/#/room/#matrix:matrix.org"; + const expectedUri = "vector://vector/webapp/#/room/#matrix:matrix.org"; + + if (platform === "darwin") { + app.emit("open-url", new Event("test"), incomingUri); + } else { + app.emit("second-instance", new Event("test"), ["/path/to/app", incomingUri]); + } + + expect(global.mainWindow!.loadURL).toHaveBeenCalledWith(expectedUri); + }); + + it("should safely deal with wrong protocol deeplinks", () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + vi.stubGlobal("mainWindow", { + loadURL: vi.fn(), + }); + + const handler = new ProtocolHandler(TEST_PROTOCOL); + expect(handler).toBeTruthy(); + + app.emit("open-url", new Event("test"), "random.proto:/#/room/#matrix:matrix.org"); + + expect(global.mainWindow!.loadURL).not.toHaveBeenCalled(); + }); + + describe("initialise", () => { + beforeEach(() => { + vi.spyOn(process, "execPath", "get").mockReturnValue("/bin/element-desktop"); + }); + + it("should set as default protocol client", () => { + const handler = new ProtocolHandler(TEST_PROTOCOL); + handler.initialise({ + userDataPath: USER_DATA_DIR, + devtools: false, + update: false, + hidden: false, + positional: ["/bin/element-desktop"], + }); + + const args = ["--no-update"]; + expect(app.setAsDefaultProtocolClient).toHaveBeenCalledWith(TEST_PROTOCOL, "/bin/element-desktop", args); + expect(app.setAsDefaultProtocolClient).toHaveBeenCalledWith("element", "/bin/element-desktop", args); + }); + + it("should handle deeplink", () => { + vi.stubGlobal("mainWindow", { + loadURL: vi.fn(), + }); + + const handler = new ProtocolHandler(TEST_PROTOCOL); + handler.initialise({ + userDataPath: "/data", + devtools: false, + update: false, + hidden: false, + positional: ["/bin/element-desktop", "test.proto:/#/room/#matrix:matrix.org"], + }); + + expect(global.mainWindow!.loadURL).toHaveBeenCalledWith("vector://vector/webapp/#/room/#matrix:matrix.org"); + }); + }); }); diff --git a/apps/desktop/src/protocol.ts b/apps/desktop/src/protocol.ts index ad2558fc2a..7d2cb527f1 100644 --- a/apps/desktop/src/protocol.ts +++ b/apps/desktop/src/protocol.ts @@ -12,6 +12,8 @@ import path from "node:path"; import fs from "node:fs"; import { randomUUID } from "node:crypto"; +import { type Args, getArgsForProtocolRegistration } from "./args.js"; + const LEGACY_PROTOCOL = "element"; const SEARCH_PARAM = "element-desktop-ssoid"; const STORE_FILE_NAME = "sso-sessions.json"; @@ -24,32 +26,19 @@ export default class ProtocolHandler { private readonly sessionId: string; public constructor(private readonly protocol: string) { - // get all args except `hidden` as it'd mean the app would not get focused - // XXX: passing args to protocol handlers only works on Windows, so unpackaged deep-linking - // --profile/--profile-dir are passed via the SEARCH_PARAM var in the callback url - const args = process.argv.slice(1).filter((arg) => arg !== "--hidden" && arg !== "-hidden"); - if (app.isPackaged) { - app.setAsDefaultProtocolClient(this.protocol, process.execPath, args); - app.setAsDefaultProtocolClient(LEGACY_PROTOCOL, process.execPath, args); - } else if (process.platform === "win32") { - // on Mac/Linux this would just cause the electron binary to open - // special handler for running without being packaged, e.g `electron .` by passing our app path to electron - app.setAsDefaultProtocolClient(this.protocol, process.execPath, [app.getAppPath(), ...args]); - app.setAsDefaultProtocolClient(LEGACY_PROTOCOL, process.execPath, [app.getAppPath(), ...args]); - } - if (process.platform === "darwin") { // Protocol handler for macos app.on("open-url", (ev, url) => { ev.preventDefault(); - this.processUrl(url); + this.handleDeeplink(url); }); } else { // Protocol handler for win32/Linux app.on("second-instance", (ev, commandLine) => { - const url = commandLine[commandLine.length - 1]; - if (!url.startsWith(`${this.protocol}:/`) && !url.startsWith(`${LEGACY_PROTOCOL}://`)) return; - this.processUrl(url); + const url = commandLine.at(-1); + if (url && this.checkArgIsUrl(url)) { + this.handleDeeplink(url); + } }); } @@ -59,6 +48,23 @@ export default class ProtocolHandler { ipcMain.handle("getProtocol", this.onGetProtocol); } + private checkArgIsUrl = (arg: string): boolean => { + return arg.startsWith(`${this.protocol}:/`) || arg.startsWith(`${LEGACY_PROTOCOL}://`); + }; + + private setAsDefaultProtocolClient(parsedArgs: Args): void { + const args = getArgsForProtocolRegistration(parsedArgs); + if (app.isPackaged) { + app.setAsDefaultProtocolClient(this.protocol, process.execPath, args); + app.setAsDefaultProtocolClient(LEGACY_PROTOCOL, process.execPath, args); + } else if (process.platform === "win32") { + // on Mac/Linux this would just cause the electron binary to open + // special handler for running without being packaged, e.g `electron .` by passing our app path to electron + app.setAsDefaultProtocolClient(this.protocol, process.execPath, [app.getAppPath(), ...args]); + app.setAsDefaultProtocolClient(LEGACY_PROTOCOL, process.execPath, [app.getAppPath(), ...args]); + } + } + private readonly onGetProtocol = (): { protocol: string; sessionId: string } => { return { protocol: this.protocol, @@ -66,8 +72,8 @@ export default class ProtocolHandler { }; }; - private processUrl(url: string): void { - if (!global.mainWindow) return; + private handleDeeplink(url: string): boolean { + if (!global.mainWindow) return false; const parsed = new URL(url); // sanity check: we only register for the one protocol, so we shouldn't @@ -75,7 +81,7 @@ export default class ProtocolHandler { // with the Element app. if (parsed.protocol !== `${this.protocol}:` && parsed.protocol !== `${LEGACY_PROTOCOL}:`) { console.log("Ignoring unexpected protocol: ", parsed.protocol); - return; + return false; } const urlToLoad = new URL("vector://vector/webapp/"); @@ -90,6 +96,7 @@ export default class ProtocolHandler { console.log("Opening URL: ", urlToLoad.href); void global.mainWindow.loadURL(urlToLoad.href); + return true; } private readStore(): Record { @@ -107,16 +114,30 @@ export default class ProtocolHandler { fs.writeFileSync(storePath, JSON.stringify(this.store)); } - public initialise(userDataPath: string): void { + /** + * Initialises the ProtocolHandler + * Registers the app as the default protocol client for deeplink handling. + * Handles any deeplink passed in via args on app start. + * Must be called after mainWindow is set up and any initial navigation is fired. + * @returns whether a deeplink was present in args and navigated to. + */ + public initialise(args: Args): boolean { + this.setAsDefaultProtocolClient(args); + + const url = args.positional.find(this.checkArgIsUrl); + const hasDeeplink = url ? this.handleDeeplink(url) : false; + for (const key in this.store) { // ensure each instance only has one (the latest) session ID to prevent the file growing unbounded - if (this.store[key] === userDataPath) { + if (this.store[key] === args.userDataPath) { delete this.store[key]; break; } } - this.store[this.sessionId] = userDataPath; + this.store[this.sessionId] = args.userDataPath; this.writeStore(); + + return hasDeeplink; } public getProfileFromDeeplink(args: string[]): string | undefined { diff --git a/apps/desktop/src/renderer-recovery.test.ts b/apps/desktop/src/renderer-recovery.test.ts new file mode 100644 index 0000000000..b50376197e --- /dev/null +++ b/apps/desktop/src/renderer-recovery.test.ts @@ -0,0 +1,350 @@ +/* +Copyright 2026 hayaksi1 + +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 { describe, it, expect, beforeEach, vi } from "vitest"; +import { type BrowserWindow, type RenderProcessGoneDetails } from "electron"; + +import { + CRASH_REASONS, + RECOVERY_ATTEMPT_CAP, + RECOVERY_WINDOW_MS, + decideRendererRecoveryAction, + RendererRecovery, + setupRendererRecovery, +} from "./renderer-recovery.js"; + +// `_t` is irrelevant to the recovery logic; stub it so importing the module under test (which pulls in +// language-helper transitively via the dialog copy) never touches the real i18n machinery. +vi.mock("./language-helper.js", () => ({ + _t: (key: string): string => key, +})); + +vi.mock("./config.js", () => ({ + getConfig: (): { brand: string } => ({ brand: "Element" }), +})); + +describe("decideRendererRecoveryAction", () => { + it.each(CRASH_REASONS)("returns 'reload' for the crash-class reason %s", (reason) => { + expect(decideRendererRecoveryAction({ reason, appQuitting: false, attemptsInWindow: 0 })).toBe("reload"); + }); + + it.each(["clean-exit", "killed", "abnormal-exit", "memory-eviction"] as const)( + "returns 'ignore' for the non-crash reason %s", + (reason) => { + expect(decideRendererRecoveryAction({ reason, appQuitting: false, attemptsInWindow: 0 })).toBe("ignore"); + }, + ); + + it("returns 'ignore' when a quit is in progress even for a crash reason", () => { + expect(decideRendererRecoveryAction({ reason: "crashed", appQuitting: true, attemptsInWindow: 0 })).toBe( + "ignore", + ); + }); + + it("returns 'reload' while still under the attempt cap", () => { + expect( + decideRendererRecoveryAction({ + reason: "crashed", + appQuitting: false, + attemptsInWindow: RECOVERY_ATTEMPT_CAP - 1, + }), + ).toBe("reload"); + }); + + it("returns 'dialog' once the attempt cap is reached (crash-loop guard)", () => { + expect( + decideRendererRecoveryAction({ + reason: "crashed", + appQuitting: false, + attemptsInWindow: RECOVERY_ATTEMPT_CAP, + }), + ).toBe("dialog"); + }); +}); + +// A fake BrowserWindow that captures the webContents listeners into a map so tests can fire them, +// mirroring the window-state.test.ts buildWin() pattern. +function buildFakeWin(): { + win: BrowserWindow; + handlers: Record void>; + reload: ReturnType; + isCrashed: ReturnType; + isDestroyed: ReturnType; +} { + const handlers: Record void> = {}; + const reload = vi.fn(); + const isCrashed = vi.fn(() => false); + const isDestroyed = vi.fn(() => false); + const win = { + isDestroyed, + webContents: { + on: vi.fn((event: string, cb: (...args: unknown[]) => void) => { + handlers[event] = cb; + }), + reload, + isCrashed, + }, + } as unknown as BrowserWindow; + return { win, handlers, reload, isCrashed, isDestroyed }; +} + +const goneDetails = (reason: RenderProcessGoneDetails["reason"]): RenderProcessGoneDetails => + ({ reason }) as RenderProcessGoneDetails; + +describe("RendererRecovery", () => { + let now = 0; + const clock = (): number => now; + const showDialog = vi.fn<() => void>(); + + beforeEach(() => { + now = 0; + showDialog.mockClear(); + }); + + it("reloads the window on a 'crashed' render-process-gone event", () => { + const { win, handlers, reload } = buildFakeWin(); + new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog }).register(); + + handlers["render-process-gone"]({}, goneDetails("crashed")); + + expect(reload).toHaveBeenCalledTimes(1); + expect(showDialog).not.toHaveBeenCalled(); + }); + + it.each(["oom", "launch-failed", "integrity-failure"] as const)( + "reloads the window on a '%s' render-process-gone event", + (reason) => { + const { win, handlers, reload } = buildFakeWin(); + new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog }).register(); + + handlers["render-process-gone"]({}, goneDetails(reason)); + + expect(reload).toHaveBeenCalledTimes(1); + }, + ); + + it("does NOT reload for 'clean-exit' or 'killed'", () => { + const { win, handlers, reload } = buildFakeWin(); + new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog }).register(); + + handlers["render-process-gone"]({}, goneDetails("clean-exit")); + handlers["render-process-gone"]({}, goneDetails("killed")); + + expect(reload).not.toHaveBeenCalled(); + expect(showDialog).not.toHaveBeenCalled(); + }); + + it("does NOT reload while the app is quitting (legitimate shutdown / macOS app.hide path)", () => { + const { win, handlers, reload } = buildFakeWin(); + new RendererRecovery({ win, clock, isQuitting: (): boolean => true, showDialog }).register(); + + handlers["render-process-gone"]({}, goneDetails("crashed")); + + expect(reload).not.toHaveBeenCalled(); + expect(showDialog).not.toHaveBeenCalled(); + }); + + it("does NOT reload a window that has already been destroyed", () => { + const { win, handlers, reload, isDestroyed } = buildFakeWin(); + isDestroyed.mockReturnValue(true); + new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog }).register(); + + handlers["render-process-gone"]({}, goneDetails("crashed")); + + expect(reload).not.toHaveBeenCalled(); + }); + + it("stops reloading after the attempt cap and shows the error dialog instead (crash-loop guard)", () => { + const { win, handlers, reload } = buildFakeWin(); + new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog }).register(); + + // The first RECOVERY_ATTEMPT_CAP crashes reload; the next one trips the cap and shows the dialog. + for (let i = 0; i < RECOVERY_ATTEMPT_CAP; i++) { + handlers["render-process-gone"]({}, goneDetails("crashed")); + } + expect(reload).toHaveBeenCalledTimes(RECOVERY_ATTEMPT_CAP); + expect(showDialog).not.toHaveBeenCalled(); + + handlers["render-process-gone"]({}, goneDetails("crashed")); + expect(reload).toHaveBeenCalledTimes(RECOVERY_ATTEMPT_CAP); // no further reload + expect(showDialog).toHaveBeenCalledTimes(1); + }); + + it("resets the attempt counter once crashes fall outside the rolling window", () => { + const { win, handlers, reload } = buildFakeWin(); + new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog }).register(); + + for (let i = 0; i < RECOVERY_ATTEMPT_CAP; i++) { + handlers["render-process-gone"]({}, goneDetails("crashed")); + } + expect(reload).toHaveBeenCalledTimes(RECOVERY_ATTEMPT_CAP); + + // Advance past the rolling window: the earlier attempts no longer count, so we reload again. + now += RECOVERY_WINDOW_MS + 1; + handlers["render-process-gone"]({}, goneDetails("crashed")); + + expect(reload).toHaveBeenCalledTimes(RECOVERY_ATTEMPT_CAP + 1); + expect(showDialog).not.toHaveBeenCalled(); + }); + + it("reloads once on the first 'unresponsive' event but not repeatedly (bounded)", () => { + const { win, handlers, reload } = buildFakeWin(); + new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog }).register(); + + handlers["unresponsive"](); + expect(reload).toHaveBeenCalledTimes(1); + + // A second hang inside the same window must not stack reloads. + handlers["unresponsive"](); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("does NOT reload on 'unresponsive' while quitting", () => { + const { win, handlers, reload } = buildFakeWin(); + new RendererRecovery({ win, clock, isQuitting: (): boolean => true, showDialog }).register(); + + handlers["unresponsive"](); + + expect(reload).not.toHaveBeenCalled(); + }); + + it("shows the error dialog (instead of reloading) when 'unresponsive' fires while already at the reload cap", () => { + const { win, handlers, reload } = buildFakeWin(); + new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog }).register(); + + // Fill the rolling window with crash reloads up to the cap so a hang now can't reload again. + for (let i = 0; i < RECOVERY_ATTEMPT_CAP; i++) { + handlers["render-process-gone"]({}, goneDetails("crashed")); + } + expect(reload).toHaveBeenCalledTimes(RECOVERY_ATTEMPT_CAP); + + handlers["unresponsive"](); + + expect(reload).toHaveBeenCalledTimes(RECOVERY_ATTEMPT_CAP); // no further reload + expect(showDialog).toHaveBeenCalledTimes(1); + }); +}); + +// A user-initiated relaunch (dock activate / second-instance) must reload a crashed renderer through the +// SAME crash-loop cap, so a relaunch can't silently re-arm a loop the recovery has already given up on. +describe("RendererRecovery.recoverIfCrashed", () => { + let now = 0; + const clock = (): number => now; + const showDialog = vi.fn<() => void>(); + + beforeEach(() => { + now = 0; + showDialog.mockClear(); + }); + + it("reloads a crashed renderer when under the cap", () => { + const { win, reload, isCrashed } = buildFakeWin(); + isCrashed.mockReturnValue(true); + const recovery = new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog }); + recovery.register(); + + recovery.recoverIfCrashed(); + + expect(reload).toHaveBeenCalledTimes(1); + expect(showDialog).not.toHaveBeenCalled(); + }); + + it("does nothing when the renderer is not crashed", () => { + const { win, reload } = buildFakeWin(); // isCrashed defaults to false + const recovery = new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog }); + recovery.register(); + + recovery.recoverIfCrashed(); + + expect(reload).not.toHaveBeenCalled(); + expect(showDialog).not.toHaveBeenCalled(); + }); + + it("does NOT reload (and shows the dialog) when crashed but the crash-loop cap was already hit", () => { + const { win, handlers, reload, isCrashed } = buildFakeWin(); + isCrashed.mockReturnValue(true); + const recovery = new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog }); + recovery.register(); + + // Exhaust the cap via genuine crash events so the loop has already been given up on. + for (let i = 0; i < RECOVERY_ATTEMPT_CAP; i++) { + handlers["render-process-gone"]({}, goneDetails("crashed")); + } + showDialog.mockClear(); + expect(reload).toHaveBeenCalledTimes(RECOVERY_ATTEMPT_CAP); + + // A user-initiated relaunch must not silently re-arm the loop. + recovery.recoverIfCrashed(); + + expect(reload).toHaveBeenCalledTimes(RECOVERY_ATTEMPT_CAP); // no extra reload + expect(showDialog).toHaveBeenCalledTimes(1); + }); + + it("counts its OWN reloads toward the crash-loop cap (relaunch can't reload past the cap)", () => { + const { win, reload, isCrashed } = buildFakeWin(); + isCrashed.mockReturnValue(true); + const recovery = new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog }); + recovery.register(); + + // Mashing dock-activate / second-instance on a crashed renderer must not reload past the cap: + // recoverIfCrashed records each of its own reloads, so the shared cap is fed both ways. + for (let i = 0; i < RECOVERY_ATTEMPT_CAP; i++) { + recovery.recoverIfCrashed(); + } + expect(reload).toHaveBeenCalledTimes(RECOVERY_ATTEMPT_CAP); + expect(showDialog).not.toHaveBeenCalled(); + + recovery.recoverIfCrashed(); + expect(reload).toHaveBeenCalledTimes(RECOVERY_ATTEMPT_CAP); // no extra reload — loop given up on + expect(showDialog).toHaveBeenCalledTimes(1); + }); + + it("does nothing when the window is destroyed", () => { + const { win, reload, isCrashed, isDestroyed } = buildFakeWin(); + isCrashed.mockReturnValue(true); + isDestroyed.mockReturnValue(true); + const recovery = new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog }); + recovery.register(); + + recovery.recoverIfCrashed(); + + expect(reload).not.toHaveBeenCalled(); + expect(showDialog).not.toHaveBeenCalled(); + }); + + it("does nothing while the app is quitting", () => { + const { win, reload, isCrashed } = buildFakeWin(); + isCrashed.mockReturnValue(true); + const recovery = new RendererRecovery({ win, clock, isQuitting: (): boolean => true, showDialog }); + recovery.register(); + + recovery.recoverIfCrashed(); + + expect(reload).not.toHaveBeenCalled(); + }); +}); + +describe("setupRendererRecovery", () => { + it("registers render-process-gone and unresponsive listeners on the window's webContents", () => { + const { win } = buildFakeWin(); + + setupRendererRecovery(win); + + const on = vi.mocked(win.webContents.on); + const events = on.mock.calls.map((c) => c[0]); + expect(events).toContain("render-process-gone"); + expect(events).toContain("unresponsive"); + }); + + it("returns the RendererRecovery instance so callers can route relaunch recovery through the cap", () => { + const { win } = buildFakeWin(); + + const recovery = setupRendererRecovery(win); + + expect(recovery).toBeInstanceOf(RendererRecovery); + }); +}); diff --git a/apps/desktop/src/renderer-recovery.ts b/apps/desktop/src/renderer-recovery.ts new file mode 100644 index 0000000000..cd4fafa504 --- /dev/null +++ b/apps/desktop/src/renderer-recovery.ts @@ -0,0 +1,240 @@ +/* +Copyright 2026 hayaksi1 + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type BrowserWindow, type RenderProcessGoneDetails, dialog } from "electron"; + +import { _t } from "./language-helper.js"; +import { getConfig } from "./config.js"; + +/** + * Auto-recovery for a dead renderer ("white screen, no UI after switching back", element-web#32222). + * + * IMPORTANT — this is a MITIGATION, not a root-cause fix. The white screen itself is an UPSTREAM + * Electron/Chromium renderer/GPU-process crash (commonly a corrupted GPUCache) that reproduces across + * Linux/Windows/macOS and which we cannot fix in this repository. What we *can* fix is the in-repo gap: + * previously there was no `render-process-gone` / `unresponsive` handler anywhere in the main process, + * so once the renderer died the window stayed permanently blank and the user had to kill the whole app. + * We turn that dead window back into a reload. The render-process-gone → reload pattern is industry + * standard (VS Code / Slack / Discord all do it). + * + * The decision logic is kept pure and the Electron wiring thin so it can be unit-tested without a GUI. + */ + +/** + * The crash-class `render-process-gone` reasons we treat as recoverable and reload for. + * + * Deliberately EXCLUDED from the union of possible reasons: + * - `clean-exit` — the renderer exited normally (e.g. teardown); nothing to recover. + * - `killed` — the process was killed (often by us / the OS on purpose); don't fight it. + * - `abnormal-exit` — ambiguous; can be an intentional kill, so we stay conservative and don't reload. + * - `memory-eviction` — Chromium reclaiming a backgrounded renderer to save memory; reloading here + * would cause spurious reloads when the window is simply hidden. + */ +export const CRASH_REASONS = ["crashed", "oom", "launch-failed", "integrity-failure"] as const; + +export type CrashReason = (typeof CRASH_REASONS)[number]; + +/** How many reloads we permit inside {@link RECOVERY_WINDOW_MS} before we give up and warn instead. */ +export const RECOVERY_ATTEMPT_CAP = 3; + +/** Rolling window over which {@link RECOVERY_ATTEMPT_CAP} is counted, to distinguish a one-off crash from a loop. */ +export const RECOVERY_WINDOW_MS = 60 * 1000; + +/** The action the recovery logic decides to take for a given `render-process-gone` event. */ +export type RecoveryAction = "reload" | "dialog" | "ignore"; + +function isCrashReason(reason: RenderProcessGoneDetails["reason"]): reason is CrashReason { + return (CRASH_REASONS as readonly string[]).includes(reason); +} + +/** + * Pure decision for what to do when the renderer is gone. No side effects, fully unit-testable. + * + * @param input.reason - the `render-process-gone` reason reported by Electron. + * @param input.appQuitting - whether a legitimate app quit is underway (so we must NOT reload). + * @param input.attemptsInWindow - reloads already performed inside the current rolling window. + * @returns `"ignore"` for benign reasons / during quit, `"dialog"` once the cap is hit (crash loop), + * otherwise `"reload"`. + */ +export function decideRendererRecoveryAction(input: { + reason: RenderProcessGoneDetails["reason"]; + appQuitting: boolean; + attemptsInWindow: number; +}): RecoveryAction { + // Never resurrect a renderer that went away as part of a legitimate shutdown (including the macOS + // app.hide() path, where we deliberately tear things down) — reloading then would fight the quit. + if (input.appQuitting) return "ignore"; + + // Only act on genuine crash-class reasons; benign exits are left alone. + if (!isCrashReason(input.reason)) return "ignore"; + + // Crash-LOOP guard: once we've already reloaded the cap's worth of times in this window, stop + // reloading (it clearly isn't recovering) and surface an error dialog instead. + if (input.attemptsInWindow >= RECOVERY_ATTEMPT_CAP) return "dialog"; + + return "reload"; +} + +/** Minimal surface of `BrowserWindow` the recovery needs — kept narrow so tests can fake it. */ +type RecoverableWindow = Pick & { + webContents: Pick; +}; + +/** Injectable dependencies so the wiring is testable without a live Electron GUI. */ +export interface RendererRecoveryDeps { + win: RecoverableWindow; + /** Returns the current time in ms (injected so the rolling window can be tested deterministically). */ + clock: () => number; + /** Whether a real quit is in progress (wraps `global.appQuitting`). */ + isQuitting: () => boolean; + /** Shows the "couldn't recover" error dialog. Injected so tests don't pop a real dialog. */ + showDialog: () => void; +} + +/** + * Stateful coordinator wrapping {@link decideRendererRecoveryAction} with the attempt accounting and + * the actual Electron side effects (reload / dialog). One instance per window. + */ +export class RendererRecovery { + /** Timestamps (per {@link RendererRecoveryDeps.clock}) of the reloads still inside the rolling window. */ + private attempts: number[] = []; + /** Whether we've already reloaded for an `unresponsive` hang in the current window (bounded once). */ + private unresponsiveHandled = false; + + public constructor(private readonly deps: RendererRecoveryDeps) {} + + /** Wire the recovery handlers onto the window's webContents. */ + public register(): void { + this.deps.win.webContents.on("render-process-gone", (_event, details) => { + this.onRenderProcessGone(details); + }); + this.deps.win.webContents.on("unresponsive", () => { + this.onUnresponsive(); + }); + } + + /** Drop attempt timestamps that have aged out of the rolling window. */ + private pruneAttempts(): void { + const cutoff = this.deps.clock() - RECOVERY_WINDOW_MS; + this.attempts = this.attempts.filter((t) => t > cutoff); + // Once the window is quiet again, allow a future hang to be recovered once more. + if (this.attempts.length === 0) this.unresponsiveHandled = false; + } + + private onRenderProcessGone(details: RenderProcessGoneDetails): void { + // MITIGATION (element-web#32222): the renderer died upstream — try to bring the UI back rather + // than leaving a permanent white screen the user can only escape by killing the whole app. + console.warn(`renderer-recovery: render-process-gone, reason=${details.reason}`); + + if (this.deps.win.isDestroyed()) return; + + this.pruneAttempts(); + const action = decideRendererRecoveryAction({ + reason: details.reason, + appQuitting: this.deps.isQuitting(), + attemptsInWindow: this.attempts.length, + }); + + this.performAction(action); + } + + /** + * Recover a renderer that is *already* crashed, driven by a user-initiated relaunch (the dock + * `activate` / `second-instance` paths in electron-main.ts) rather than a `render-process-gone` + * event. Routed through the SAME attempt cap as {@link onRenderProcessGone} so a relaunch can't + * silently re-arm a crash loop we've already given up on (element-web#32222) — once the cap is hit + * the user gets the error dialog instead of yet another reload. + */ + public recoverIfCrashed(): void { + if (this.deps.win.isDestroyed()) return; + if (!this.deps.win.webContents.isCrashed()) return; + + this.pruneAttempts(); + const action = decideRendererRecoveryAction({ + // The renderer is crashed (isCrashed() above); treat it as a crash-class recovery. + reason: "crashed", + appQuitting: this.deps.isQuitting(), + attemptsInWindow: this.attempts.length, + }); + + this.performAction(action); + } + + /** Execute the decided {@link RecoveryAction}: reload (recording the attempt) / dialog / ignore. */ + private performAction(action: RecoveryAction): void { + switch (action) { + case "reload": + this.attempts.push(this.deps.clock()); + console.warn( + `renderer-recovery: reloading renderer (attempt ${this.attempts.length}/${RECOVERY_ATTEMPT_CAP})`, + ); + this.deps.win.webContents.reload(); + break; + case "dialog": + console.error("renderer-recovery: renderer crash loop detected, giving up and warning the user"); + this.deps.showDialog(); + break; + case "ignore": + break; + } + } + + private onUnresponsive(): void { + // A hung (not crashed) renderer: conservatively reload at most once per rolling window, and never + // during a quit. We reuse the same attempt cap so a hang-loop can't reload forever either. + console.warn("renderer-recovery: renderer unresponsive"); + + if (this.deps.win.isDestroyed() || this.deps.isQuitting()) return; + + this.pruneAttempts(); + if (this.unresponsiveHandled) return; + if (this.attempts.length >= RECOVERY_ATTEMPT_CAP) { + console.error("renderer-recovery: unresponsive while already at the reload cap, warning the user"); + this.deps.showDialog(); + return; + } + + this.unresponsiveHandled = true; + this.attempts.push(this.deps.clock()); + console.warn("renderer-recovery: reloading unresponsive renderer"); + this.deps.win.webContents.reload(); + } +} + +/** + * Show the "we couldn't recover the window" error dialog. Mirrors the dialog/i18n convention used by + * store.ts / electron-main.ts (`dialog.showMessageBox` + `_t`). + */ +function showCrashLoopDialog(win: BrowserWindow): void { + const brand = getConfig().brand; + void dialog.showMessageBox(win, { + type: "error", + title: _t("renderer_crash|title", { brand }), + message: _t("renderer_crash|message", { brand }), + detail: _t("renderer_crash|detail"), + buttons: [_t("action|close")], + }); +} + +/** + * Install renderer auto-recovery on the main window. Thin Electron wiring around {@link RendererRecovery}; + * follows the `setupX(win)` named-export seam used by media-auth.ts / media-permissions.ts. + * + * @param win - the main BrowserWindow whose renderer we guard. + * @returns the {@link RendererRecovery} instance so the caller can route user-initiated relaunch + * recovery (dock `activate` / `second-instance`) through {@link RendererRecovery.recoverIfCrashed}. + */ +export function setupRendererRecovery(win: BrowserWindow): RendererRecovery { + const recovery = new RendererRecovery({ + win, + clock: (): number => Date.now(), + isQuitting: (): boolean => global.appQuitting, + showDialog: (): void => showCrashLoopDialog(win), + }); + recovery.register(); + return recovery; +} diff --git a/apps/desktop/src/save-image.test.ts b/apps/desktop/src/save-image.test.ts new file mode 100644 index 0000000000..f1ac82db6f --- /dev/null +++ b/apps/desktop/src/save-image.test.ts @@ -0,0 +1,135 @@ +/* +Copyright 2026 hayaksi1 + +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 { expect, describe, it, beforeEach, vi, type Mock } from "vitest"; +import { nativeImage, type Session } from "electron"; +import fs from "node:fs"; +import * as streamPromises from "node:stream/promises"; + +import { saveImageToFile, writeNativeImage } from "./save-image.js"; + +vi.mock("electron", () => ({ + nativeImage: { + createFromDataURL: vi.fn(), + }, +})); + +vi.mock("node:fs", () => ({ + default: { + createWriteStream: vi.fn(), + promises: { + writeFile: vi.fn(() => Promise.resolve()), + }, + }, +})); + +vi.mock("node:stream/promises", () => ({ + pipeline: vi.fn(() => Promise.resolve()), +})); + +const createFromDataURL = vi.mocked(nativeImage.createFromDataURL); +const createWriteStream = vi.mocked(fs.createWriteStream); +const writeFile = vi.mocked(fs.promises.writeFile); +const pipeline = vi.mocked(streamPromises.pipeline); + +/** A stub {@link NativeImage} exposing the encoder methods `writeNativeImage` selects between. */ +function stubNativeImage(): { toPNG: Mock; toJPEG: Mock; toBitmap: Mock } { + return { + toPNG: vi.fn(() => Buffer.from("png")), + toJPEG: vi.fn(() => Buffer.from("jpeg")), + toBitmap: vi.fn(() => Buffer.from("bmp")), + }; +} + +/** A fake Electron {@link Session} exposing only the `fetch` method used by `saveImageToFile`. */ +function fakeSession(fetchImpl: Mock): Session { + return { fetch: fetchImpl } as unknown as Session; +} + +describe("save-image", () => { + beforeEach(() => { + vi.clearAllMocks(); + createFromDataURL.mockReturnValue(stubNativeImage() as never); + createWriteStream.mockReturnValue({} as never); + }); + + describe("saveImageToFile", () => { + it("decodes a data: URL into a NativeImage and writes it without fetching", async () => { + const session = fakeSession(vi.fn()); + const globalFetch = vi.spyOn(globalThis, "fetch"); + + await saveImageToFile("data:image/png;base64,AAAA", "/tmp/out.png", session); + + expect(createFromDataURL).toHaveBeenCalledWith("data:image/png;base64,AAAA"); + expect(writeFile).toHaveBeenCalledTimes(1); + expect(session.fetch).not.toHaveBeenCalled(); + expect(globalFetch).not.toHaveBeenCalled(); + }); + + it("fetches http(s) URLs through the injected session and pipes the body to disk", async () => { + const body = { kind: "stream" }; + const fetchImpl = vi.fn(() => Promise.resolve({ ok: true, body })); + const session = fakeSession(fetchImpl); + const writeStream = { kind: "writeStream" }; + createWriteStream.mockReturnValue(writeStream as never); + const globalFetch = vi.spyOn(globalThis, "fetch"); + + await saveImageToFile("https://hs.example/_matrix/media/v3/download/x/y", "/tmp/out.png", session); + + // Regression assertion (#32362): the injected session fetch is used so the media-auth + // webRequest interceptors apply; the main-process global fetch must NOT be called. + expect(fetchImpl).toHaveBeenCalledWith("https://hs.example/_matrix/media/v3/download/x/y"); + expect(globalFetch).not.toHaveBeenCalled(); + expect(createWriteStream).toHaveBeenCalledWith("/tmp/out.png"); + expect(pipeline).toHaveBeenCalledWith(body, writeStream); + }); + + it("throws when the session fetch responds with a non-ok status", async () => { + const fetchImpl = vi.fn(() => Promise.resolve({ ok: false, statusText: "Not Found" })); + const session = fakeSession(fetchImpl); + + await expect(saveImageToFile("https://hs.example/image.png", "/tmp/out.png", session)).rejects.toThrow( + "unexpected response Not Found", + ); + expect(pipeline).not.toHaveBeenCalled(); + }); + + it("throws when the session fetch responds without a body", async () => { + const fetchImpl = vi.fn(() => Promise.resolve({ ok: true, body: null, statusText: "OK" })); + const session = fakeSession(fetchImpl); + + await expect(saveImageToFile("https://hs.example/image.png", "/tmp/out.png", session)).rejects.toThrow( + "unexpected response has no body OK", + ); + expect(pipeline).not.toHaveBeenCalled(); + }); + }); + + describe("writeNativeImage", () => { + it("encodes .jpg/.jpeg as JPEG", async () => { + const img = stubNativeImage(); + await writeNativeImage("/tmp/out.jpg", img as never); + expect(img.toJPEG).toHaveBeenCalledWith(100); + expect(img.toPNG).not.toHaveBeenCalled(); + expect(img.toBitmap).not.toHaveBeenCalled(); + }); + + it("encodes .bmp as a bitmap", async () => { + const img = stubNativeImage(); + await writeNativeImage("/tmp/out.bmp", img as never); + expect(img.toBitmap).toHaveBeenCalled(); + expect(img.toPNG).not.toHaveBeenCalled(); + }); + + it("encodes unknown extensions as PNG", async () => { + const img = stubNativeImage(); + await writeNativeImage("/tmp/out.weird", img as never); + expect(img.toPNG).toHaveBeenCalled(); + expect(img.toJPEG).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/desktop/src/save-image.ts b/apps/desktop/src/save-image.ts new file mode 100644 index 0000000000..640c42a015 --- /dev/null +++ b/apps/desktop/src/save-image.ts @@ -0,0 +1,52 @@ +/* +Copyright 2026 hayaksi1 + +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 { nativeImage, type NativeImage, type Session } from "electron"; +import fs from "node:fs"; +import { pipeline } from "node:stream/promises"; + +/** + * Writes an Electron {@link NativeImage} to disk, encoding it based on the target file extension. + * Falls back to PNG for unknown extensions. + */ +export function writeNativeImage(filePath: string, img: NativeImage): Promise { + switch (filePath.split(".").pop()?.toLowerCase()) { + case "jpg": + case "jpeg": + return fs.promises.writeFile(filePath, img.toJPEG(100)); + case "bmp": + return fs.promises.writeFile(filePath, img.toBitmap()); + case "png": + default: + return fs.promises.writeFile(filePath, img.toPNG()); + } +} + +/** + * Saves an image to a file on disk. + * + * `data:` URLs are decoded directly into a {@link NativeImage}. Network (`http(s):`) URLs are + * fetched through the supplied Electron {@link Session} rather than Node's global `fetch`, so that + * the session's `webRequest` interceptors apply — in particular the authenticated-media handlers in + * `media-auth.ts` which rewrite the download URL and attach the `Authorization` header. Using the + * main-process global `fetch` bypasses those interceptors and fails with 401/404 on modern Synapse + * (authenticated media, MSC3916). See https://github.com/element-hq/element-web/issues/32362. + * + * @param url - the `data:` or `http(s):` URL of the image to save + * @param filePath - the destination path on disk + * @param session - the Electron session whose `webRequest` interceptors should apply to the fetch + */ +export async function saveImageToFile(url: string, filePath: string, session: Session): Promise { + if (url.startsWith("data:")) { + await writeNativeImage(filePath, nativeImage.createFromDataURL(url)); + } else { + const resp = await session.fetch(url); + if (!resp.ok) throw new Error(`unexpected response ${resp.statusText}`); + if (!resp.body) throw new Error(`unexpected response has no body ${resp.statusText}`); + await pipeline(resp.body, fs.createWriteStream(filePath)); + } +} diff --git a/apps/desktop/src/store.ts b/apps/desktop/src/store.ts index dee629be92..12ff4d9472 100644 --- a/apps/desktop/src/store.ts +++ b/apps/desktop/src/store.ts @@ -18,6 +18,7 @@ import ElectronStore from "electron-store"; import { app, safeStorage, dialog, type SafeStorage, type Session } from "electron"; import { _t } from "./language-helper.js"; +import { getConfig } from "./config.js"; /** * String union type representing all the safeStorage backends. @@ -126,7 +127,7 @@ class SafeStorageWriter extends StorageWriter { } } -const enum Mode { +export const enum Mode { Encrypted = "encrypted", // default AllowPlaintext = "allow-plaintext", ForcePlaintext = "force-plaintext", @@ -373,7 +374,7 @@ class Store extends ElectronStore { message: _t("store|error|backend_no_encryption"), detail: _t("store|error|backend_no_encryption_detail", { backend: safeStorage.getSelectedStorageBackend(), - brand: global.vectorConfig.brand || "Element", + brand: getConfig().brand, }), type: "error", buttons: [_t("action|cancel"), _t("store|error|unsupported_keyring_use_plaintext")], @@ -389,7 +390,7 @@ class Store extends ElectronStore { title: _t("store|error|unsupported_keyring_title"), message: _t("store|error|unsupported_keyring"), detail: _t("store|error|unsupported_keyring_detail", { - brand: global.vectorConfig.brand || "Element", + brand: getConfig().brand, link: "https://www.electronjs.org/docs/latest/api/safe-storage#safestoragegetselectedstoragebackend-linux", }), type: "error", diff --git a/apps/desktop/src/tray.test.ts b/apps/desktop/src/tray.test.ts new file mode 100644 index 0000000000..9820d06639 --- /dev/null +++ b/apps/desktop/src/tray.test.ts @@ -0,0 +1,50 @@ +/* +Copyright 2026 Element Creations 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 { expect, describe, it, beforeEach, vi } from "vitest"; +import { Tray } from "electron"; + +import { getConfig } from "./config.js"; + +vi.mock("electron", () => ({ + Tray: vi.fn( + class { + public setToolTip = vi.fn(); + public setContextMenu = vi.fn(); + public on = vi.fn(); + }, + ), + Menu: { + buildFromTemplate: vi.fn(), + }, + nativeImage: { + createFromPath: vi.fn(), + }, + app: { + isPackaged: true, + }, +})); + +vi.mock("./icon.js"); +vi.mock("./config.js"); + +describe("create", () => { + let create: () => Promise; + + beforeEach(async () => { + // The tray is disabled on macOS so test under win32 + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + ({ create } = await import("./tray.js")); + }); + + it("should use config.brand", async () => { + vi.mocked(getConfig).mockReturnValue({ brand: "ChatApp", help_url: "HELP_URL", web_base_url: "WEB_BASE_URL" }); + await create(); + const tray = vi.mocked(Tray).mock.instances[0]; + expect(tray.setToolTip).toHaveBeenCalledWith("ChatApp"); + }); +}); diff --git a/apps/desktop/src/tray.ts b/apps/desktop/src/tray.ts index f6833f2f2f..3d8015aba8 100644 --- a/apps/desktop/src/tray.ts +++ b/apps/desktop/src/tray.ts @@ -15,8 +15,8 @@ import path from "node:path"; import { _t } from "./language-helper.js"; import { getBuildConfig } from "./build-config.js"; -import { getBrand } from "./config.js"; import { getIconPath } from "./icon.js"; +import { getConfig } from "./config.js"; // This hardcoded uuid is an arbitrary v4 uuid generated on https://www.uuidgenerator.net/version4 const UUID_NAMESPACE = "9fc9c6a0-9ffe-45c9-9cd7-5639ae38b232"; @@ -62,7 +62,7 @@ export async function create(): Promise { trayIcon = new Tray(defaultIcon); } - trayIcon.setToolTip(getBrand()); + trayIcon.setToolTip(getConfig().brand); initApplicationMenu(); trayIcon.on("click", toggleWin); diff --git a/apps/desktop/src/updater.ts b/apps/desktop/src/updater.ts index 5b35fc5a09..e6702d38c5 100644 --- a/apps/desktop/src/updater.ts +++ b/apps/desktop/src/updater.ts @@ -12,7 +12,7 @@ import os from "node:os"; import { getSquirrelExecutable } from "./squirrelhooks.js"; import { _t } from "./language-helper.js"; import { initialisePromise } from "./ipc.js"; -import { getBrand } from "./config.js"; +import { getConfig } from "./config.js"; const UPDATE_POLL_INTERVAL_MS = 60 * 60 * 1000; const INITIAL_UPDATE_DELAY_MS = 30 * 1000; @@ -150,7 +150,7 @@ async function available(): Promise { initialisePromise.then(() => { ipcMain.emit("showToast", { title: _t("eol|title"), - description: _t("eol|no_more_updates", { brand: getBrand() }), + description: _t("eol|no_more_updates", { brand: getConfig().brand }), }); }); console.warn("Auto update not supported, macOS version too old"); @@ -161,7 +161,7 @@ async function available(): Promise { initialisePromise.then(() => { ipcMain.emit("showToast", { title: _t("eol|title"), - description: _t("eol|warning", { brand: getBrand() }), + description: _t("eol|warning", { brand: getConfig().brand }), }); }); } diff --git a/apps/desktop/src/utils.test.ts b/apps/desktop/src/utils.test.ts new file mode 100644 index 0000000000..aa099c6f65 --- /dev/null +++ b/apps/desktop/src/utils.test.ts @@ -0,0 +1,78 @@ +/* +Copyright 2026 Element Creations 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 { expect, describe, it, beforeEach, vi } from "vitest"; +import { fs as memfs, vol } from "memfs"; + +import { loadJsonFile, tryPaths, randomArray } from "./utils.js"; + +vi.mock("node:fs", () => ({ default: memfs })); +vi.mock("node:fs/promises", () => ({ default: memfs.promises })); + +beforeEach(() => { + // Reset the state of the in-memory fs + vol.reset(); +}); + +describe("randomArray", () => { + it("should return an array matching the requested size", async () => { + function toUnpaddedBase64Size(size: number): number { + return Math.ceil((4 * size) / 3); + } + + await expect(randomArray(100)).resolves.toHaveLength(toUnpaddedBase64Size(100)); + await expect(randomArray(32)).resolves.toHaveLength(toUnpaddedBase64Size(32)); + }); + + it("should return a unique random array", async () => { + const arr1 = await randomArray(60); + const arr2 = await randomArray(60); + expect(arr1).not.toEqual(arr2); + }); +}); + +describe("loadJsonFile", () => { + beforeEach(() => { + vol.fromJSON({ + "./file.json": JSON.stringify({ file1: true }), + "./nested/deep/file.json": JSON.stringify({ file2: true }), + }); + }); + + it("should load and parse a JSON file correctly", () => { + expect(loadJsonFile("file.json")).toStrictEqual({ file1: true }); + }); + + it("should use args as path segments", () => { + expect(loadJsonFile("nested", "deep", "file.json")).toStrictEqual({ file2: true }); + }); + + it("should return an empty object when file does not exist", () => { + expect(loadJsonFile("unknown-file.json")).toStrictEqual({}); + }); +}); + +describe("tryPaths", () => { + beforeEach(() => { + vol.fromNestedJSON({ + "./dirA/": {}, + "./dir/dirB/": {}, + }); + }); + + it("should find file relative to given root", async () => { + await expect(tryPaths("name", "dir", ["dirB"])).resolves.toEqual("dir/dirB/"); + }); + + it("should handle unknown paths", async () => { + await expect(tryPaths("name", ".", ["dirB", "dirA"])).resolves.toEqual("dirA/"); + }); + + it("should throw error if file does not exist", async () => { + await expect(tryPaths("name", "dir", ["a.json", "b.json"])).rejects.toThrow("Failed to find name path"); + }); +}); diff --git a/apps/desktop/src/utils.ts b/apps/desktop/src/utils.ts index 368211aba7..eedabf5237 100644 --- a/apps/desktop/src/utils.ts +++ b/apps/desktop/src/utils.ts @@ -9,7 +9,12 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import afs from "node:fs/promises"; +import { type JsonDocument } from "shared-types"; +/** + * Returns a random array of a specified size in unpadded base64 + * @param size - the size of the underlying random array + */ export async function randomArray(size: number): Promise { return new Promise((resolve, reject) => { crypto.randomBytes(size, (err, buf) => { @@ -22,19 +27,12 @@ export async function randomArray(size: number): Promise { }); } -type JsonValue = null | string | number; -type JsonArray = Array; -export interface JsonObject { - [key: string]: JsonObject | JsonArray | JsonValue; -} -export type Json = JsonArray | JsonObject; - /** * Synchronously load a JSON file from the local filesystem. * Unlike `require`, will never execute any javascript in a loaded file. * @param paths - An array of path segments which will be joined using the system's path delimiter. */ -export function loadJsonFile(...paths: string[]): T { +export function loadJsonFile(...paths: string[]): T { const joinedPaths = path.join(...paths); if (!fs.existsSync(joinedPaths)) { @@ -62,9 +60,9 @@ export async function tryPaths(name: string, root: string, rawPaths: string[]): return p + "/"; } catch {} } - console.log(`Couldn't find ${name} files in any of: `); + console.log(`Couldn't find '${name}' in any of: `); for (const p of paths) { console.log("\t" + path.resolve(p)); } - throw new Error(`Failed to find ${name} files`); + throw new Error(`Failed to find ${name} path`); } diff --git a/apps/desktop/src/vectormenu.test.ts b/apps/desktop/src/vectormenu.test.ts new file mode 100644 index 0000000000..5b3582e7c7 --- /dev/null +++ b/apps/desktop/src/vectormenu.test.ts @@ -0,0 +1,61 @@ +/* +Copyright 2026 Element Creations 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 { expect, describe, it, vi, beforeEach } from "vitest"; +import { type Menu, type MenuItemConstructorOptions, shell } from "electron"; + +import { type buildMenuTemplate as _buildMenuTemplate } from "./vectormenu.js"; +import { type ConfigOptions } from "./config.js"; + +vi.mock("electron", () => ({ + app: { + name: "ChatApp", + }, + shell: { + openExternal: vi.fn(), + }, + Menu: { buildFromTemplate: ((items) => ({ items })) as (typeof Menu)["buildFromTemplate"] } as unknown as Menu, +})); + +vi.mock("./config.js", () => ({ + getConfig: (): Partial => ({ + brand: "IAMBRAND", + help_url: "https://i.need.help", + }), +})); + +vi.mock("./language-helper.js", () => ({ + _t: (k: string): string => k, +})); + +describe("buildMenuTemplate", () => { + describe.each(["darwin", "linux", "win32"] as const)("on %s", (platform) => { + let buildMenuTemplate: typeof _buildMenuTemplate; + + beforeEach(async () => { + vi.spyOn(process, "platform", "get").mockReturnValue(platform); + vi.resetModules(); + ({ buildMenuTemplate } = await import("./vectormenu.js")); + }); + + it.runIf(platform === "darwin")("should have an app-named item first", () => { + const menu = buildMenuTemplate(); + expect(menu.items[0].label).toBe("ChatApp"); + }); + + it("should include expected `help` menu", () => { + const menu = buildMenuTemplate(); + + const helpMenu = menu.items.at(-1)!; + expect(helpMenu.label).toBe("common|help"); + const helpSubmenu = helpMenu.submenu as unknown as MenuItemConstructorOptions[]; + expect(helpSubmenu[0].label).toBe("common|brand_help"); + helpSubmenu[0].click!(menu.items.at(-1)!, undefined, new Event("click") as KeyboardEvent); + expect(shell.openExternal).toHaveBeenCalledWith("https://i.need.help"); + }); + }); +}); diff --git a/apps/desktop/src/vectormenu.ts b/apps/desktop/src/vectormenu.ts index d5bab05ed5..6680396e90 100644 --- a/apps/desktop/src/vectormenu.ts +++ b/apps/desktop/src/vectormenu.ts @@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details. import { app, shell, Menu, type MenuItem, type MenuItemConstructorOptions } from "electron"; import { _t } from "./language-helper.js"; +import { getConfig } from "./config.js"; const isMac = process.platform === "darwin"; @@ -129,10 +130,9 @@ export function buildMenuTemplate(): Menu { role: "help", submenu: [ { - // XXX: vectorConfig won't have defaults applied to it so we need to duplicate them here - label: _t("common|brand_help", { brand: global.vectorConfig?.brand || "Element" }), + label: _t("common|brand_help", { brand: getConfig().brand }), click(): void { - void shell.openExternal(global.vectorConfig?.help_url || "https://element.io/help"); + void shell.openExternal(getConfig().help_url); }, }, ], diff --git a/apps/desktop/src/webcontents-handler.test.ts b/apps/desktop/src/webcontents-handler.test.ts new file mode 100644 index 0000000000..e629352f9f --- /dev/null +++ b/apps/desktop/src/webcontents-handler.test.ts @@ -0,0 +1,224 @@ +/* +Copyright 2026 hayaksi1 + +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 { beforeEach, describe, expect, it, vi, type Mock } from "vitest"; +import { dialog, shell, type WebContents } from "electron"; + +// The `userDownloadAction` listener is registered at import time, so capture the callbacks that +// `ipcMain.on` receives in order to invoke the handler under test directly. +const { ipcHandlers, menus } = vi.hoisted(() => ({ + ipcHandlers: {} as Record unknown>, + // Every context menu the handler builds, in construction order, so a test can click an entry. + menus: [] as MenuStub[], +})); + +interface MenuItemStub { + label: string; + click?: () => void | Promise; +} + +interface MenuStub { + items: MenuItemStub[]; +} + +vi.mock("electron", () => ({ + clipboard: { writeText: vi.fn() }, + Menu: class { + public readonly items: MenuItemStub[] = []; + public constructor() { + menus.push(this); + } + public append(item: MenuItemStub): void { + this.items.push(item); + } + public popup(): void {} + }, + MenuItem: class { + public readonly label: string; + public readonly click?: () => void | Promise; + public constructor(options: MenuItemStub) { + this.label = options.label; + this.click = options.click; + } + }, + shell: { openExternal: vi.fn(), openPath: vi.fn() }, + dialog: { showMessageBox: vi.fn(), showSaveDialog: vi.fn() }, + ipcMain: { + on: vi.fn((channel: string, cb: (...args: unknown[]) => unknown) => { + ipcHandlers[channel] = cb; + }), + }, +})); +vi.mock("./language-helper.js", () => ({ _t: (key: string): string => key })); +vi.mock("./config.js", () => ({ getConfig: (): Record => ({}) })); +vi.mock("./save-image.js", () => ({ saveImageToFile: vi.fn() })); + +const registerWebContentsHandlers = (await import("./webcontents-handler.js")).default; + +interface MockWebContents { + setWindowOpenHandler: Mock; + on: Mock; + send: Mock; + copyImageAt: Mock; + session: { on: Mock }; + handlers: Record void>; + sessionHandlers: Record void>; +} + +function makeWebContents(): MockWebContents { + const handlers: Record void> = {}; + const sessionHandlers: Record void> = {}; + return { + setWindowOpenHandler: vi.fn(), + on: vi.fn((ev: string, cb: (...args: unknown[]) => void): void => { + handlers[ev] = cb; + }), + send: vi.fn(), + copyImageAt: vi.fn(), + session: { + on: vi.fn((ev: string, cb: (...args: unknown[]) => void): void => { + sessionHandlers[ev] = cb; + }), + }, + handlers, + sessionHandlers, + }; +} + +interface MockDownloadItem { + once: (ev: string, cb: (...args: unknown[]) => void) => void; + getFilename: () => string; + getSavePath: () => string; + setSaveDialogOptions: Mock; + doneHandlers: Record void>; +} + +function makeDownloadItem(savePath: string): MockDownloadItem { + const doneHandlers: Record void> = {}; + return { + once: (ev: string, cb: (...args: unknown[]) => void): void => { + doneHandlers[ev] = cb; + }, + getFilename: (): string => savePath.split("/").pop()!, + getSavePath: (): string => savePath, + setSaveDialogOptions: vi.fn(), + doneHandlers, + }; +} + +/** + * Drives the real will-download → done("completed") flow so the download is registered the way it is + * in production, and returns the id the handler assigned to it. + */ +function completeDownload(wc: MockWebContents, savePath: string): number { + const item = makeDownloadItem(savePath); + wc.sessionHandlers["will-download"]({}, item); + item.doneHandlers["done"]({}, "completed"); + const completed = wc.send.mock.calls.find((c) => c[0] === "userDownloadCompleted"); + return (completed![1] as { id: number }).id; +} + +describe("userDownloadAction handler", () => { + let wc: MockWebContents; + + beforeEach(() => { + vi.clearAllMocks(); + wc = makeWebContents(); + registerWebContentsHandlers(wc as unknown as WebContents); + }); + + it("opens the file when the user clicks Open on a known download", async () => { + vi.mocked(shell.openPath).mockResolvedValue(""); + const id = completeDownload(wc, "/tmp/file.pdf"); + + await ipcHandlers["userDownloadAction"]({}, { id, open: true }); + + expect(shell.openPath).toHaveBeenCalledWith("/tmp/file.pdf"); + expect(dialog.showMessageBox).not.toHaveBeenCalled(); + }); + + it("shows the underlying error when the open fails, rather than failing silently", async () => { + vi.mocked(shell.openPath).mockResolvedValue("LSOpenURLsWithRole failed"); + const id = completeDownload(wc, "/tmp/file.pdf"); + + await ipcHandlers["userDownloadAction"]({}, { id, open: true }); + + expect(shell.openPath).toHaveBeenCalledWith("/tmp/file.pdf"); + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ type: "error", detail: "LSOpenURLsWithRole failed" }), + ); + }); + + it("does not open anything on a plain dismiss", async () => { + const id = completeDownload(wc, "/tmp/file.pdf"); + + await ipcHandlers["userDownloadAction"]({}, { id, open: false }); + + expect(shell.openPath).not.toHaveBeenCalled(); + }); + + it("removes the entry so a repeated open is a no-op", async () => { + vi.mocked(shell.openPath).mockResolvedValue(""); + const id = completeDownload(wc, "/tmp/file.pdf"); + + await ipcHandlers["userDownloadAction"]({}, { id, open: true }); + vi.mocked(shell.openPath).mockClear(); + await ipcHandlers["userDownloadAction"]({}, { id, open: true }); + + expect(shell.openPath).not.toHaveBeenCalled(); + }); +}); + +describe("save dialog filters", () => { + let wc: MockWebContents; + + beforeEach(() => { + vi.clearAllMocks(); + menus.length = 0; + wc = makeWebContents(); + registerWebContentsHandlers(wc as unknown as WebContents); + }); + + it("names a download's own file type, so renaming it does not strip the extension", () => { + const item = makeDownloadItem("/tmp/photo.jpg"); + + wc.sessionHandlers["will-download"]({}, item); + + expect(item.setSaveDialogOptions).toHaveBeenCalledWith({ + filters: [expect.objectContaining({ extensions: ["jpg"] }), expect.objectContaining({ extensions: ["*"] })], + }); + }); + + it("leaves the dialog alone for a download which has no extension to preserve", () => { + const item = makeDownloadItem("/tmp/archive"); + + wc.sessionHandlers["will-download"]({}, item); + + expect(item.setSaveDialogOptions).not.toHaveBeenCalled(); + }); + + it("names the file type when saving an image from the context menu too", async () => { + vi.mocked(dialog.showSaveDialog).mockResolvedValue({ canceled: false, filePath: "/tmp/renamed.jpg" }); + + wc.handlers["context-menu"]( + { preventDefault: vi.fn() }, + { srcURL: "https://example.org/photo.jpg", hasImageContents: true, suggestedFilename: "photo.jpg" }, + ); + const saveAs = menus[0].items.find((item) => item.label === "right_click_menu|save_image_as"); + await saveAs!.click!(); + + expect(dialog.showSaveDialog).toHaveBeenCalledWith( + expect.objectContaining({ + defaultPath: "photo.jpg", + filters: [ + expect.objectContaining({ extensions: ["jpg"] }), + expect.objectContaining({ extensions: ["*"] }), + ], + }), + ); + }); +}); diff --git a/apps/desktop/src/webcontents-handler.ts b/apps/desktop/src/webcontents-handler.ts index 78dbfd791b..15dfdcfd84 100644 --- a/apps/desktop/src/webcontents-handler.ts +++ b/apps/desktop/src/webcontents-handler.ts @@ -7,31 +7,51 @@ Please see LICENSE files in the repository root for full details. import { clipboard, - nativeImage, Menu, MenuItem, shell, dialog, ipcMain, - type NativeImage, type WebContents, type ContextMenuParams, type DownloadItem, + type FileFilter, type MenuItemConstructorOptions, type IpcMainEvent, type Event, } from "electron"; import url from "node:url"; -import fs from "node:fs"; -import { pipeline } from "node:stream/promises"; import path from "node:path"; import { _t } from "./language-helper.js"; +import { saveImageToFile } from "./save-image.js"; +import { getConfig } from "./config.js"; const MAILTO_PREFIX = "mailto:"; const PERMITTED_URL_SCHEMES: string[] = ["http:", "https:", MAILTO_PREFIX]; +/** + * Work out the filters a save dialog should offer so that a file keeps its own extension. + * + * A dialog which only offers "All Files" lets someone replace "photo.jpg" with "photo" and end up + * with a file the shell no longer knows how to open — the extension is simply gone. Naming the + * file's own type first means the dialog puts the extension back, which is what a browser already + * does for the same download. + * + * @param fileName - The name being suggested to the user, which may carry no extension at all. + * @returns Filters to pass to a save dialog, or undefined when there is no extension to preserve. + */ +function saveDialogFilters(fileName: string): FileFilter[] | undefined { + // extname() keeps the leading dot, and returns an empty string for a name which has none. + const extension = path.extname(fileName).slice(1); + if (!extension) return undefined; + return [ + { name: _t("save_dialog|named_file_type", { extension: extension.toUpperCase() }), extensions: [extension] }, + { name: _t("save_dialog|all_files"), extensions: ["*"] }, + ]; +} + function safeOpenURL(target: string): void { // openExternal passes the target to open/start/xdg-open, // so put fairly stringent limits on what can be opened @@ -56,26 +76,13 @@ function onWindowOrNavigate(ev: Event, target: string): void { safeOpenURL(target); } -function writeNativeImage(filePath: string, img: NativeImage): Promise { - switch (filePath.split(".").pop()?.toLowerCase()) { - case "jpg": - case "jpeg": - return fs.promises.writeFile(filePath, img.toJPEG(100)); - case "bmp": - return fs.promises.writeFile(filePath, img.toBitmap()); - case "png": - default: - return fs.promises.writeFile(filePath, img.toPNG()); - } -} - function onLinkContextMenu(ev: Event, params: ContextMenuParams, webContents: WebContents): void { let url = params.linkURL || params.srcURL; if (url.startsWith("vector://vector/webapp")) { // Avoid showing a context menu for app icons if (params.hasImageContents) return; - const baseUrl = vectorConfig.web_base_url ?? "https://app.element.io/"; + const baseUrl = getConfig().web_base_url; // Rewrite URL so that it can be used outside the app url = baseUrl + url.substring(23); } @@ -144,19 +151,13 @@ function onLinkContextMenu(ev: Event, params: ContextMenuParams, webContents: We const targetFileName = params.suggestedFilename || params.altText || "image.png"; const { filePath } = await dialog.showSaveDialog({ defaultPath: targetFileName, + filters: saveDialogFilters(targetFileName), }); if (!filePath) return; // user cancelled dialog try { - if (url.startsWith("data:")) { - await writeNativeImage(filePath, nativeImage.createFromDataURL(url)); - } else { - const resp = await fetch(url); - if (!resp.ok) throw new Error(`unexpected response ${resp.statusText}`); - if (!resp.body) throw new Error(`unexpected response has no body ${resp.statusText}`); - await pipeline(resp.body, fs.createWriteStream(filePath)); - } + await saveImageToFile(url, filePath, webContents.session); } catch (err) { console.error(err); void dialog.showMessageBox({ @@ -265,12 +266,22 @@ function onEditableContextMenu(ev: Event, params: ContextMenuParams, webContents let userDownloadIndex = 0; const userDownloadMap = new Map(); // Map from id to path -ipcMain.on("userDownloadAction", function (ev: IpcMainEvent, { id, open = false }) { +ipcMain.on("userDownloadAction", async function (ev: IpcMainEvent, { id, open = false }) { const path = userDownloadMap.get(id); - if (open && path) { - void shell.openPath(path); - } userDownloadMap.delete(id); + if (open && path) { + // openPath resolves to a non-empty error string on failure, an empty one on success. + const error = await shell.openPath(path); + if (error) { + console.error(`Failed to open downloaded file ${path}: ${error}`); + void dialog.showMessageBox({ + type: "error", + title: _t("download|unable_to_open_title"), + message: _t("download|unable_to_open_description"), + detail: error, + }); + } + } }); export default (webContents: WebContents): void => { @@ -295,6 +306,11 @@ export default (webContents: WebContents): void => { }); webContents.session.on("will-download", (event: Event, item: DownloadItem): void => { + // Electron only offers "All Files" unless it is told otherwise, so say what this download is + // before it puts the save dialog up. + const filters = saveDialogFilters(item.getFilename()); + if (filters) item.setSaveDialogOptions({ filters }); + item.once("done", (event, state) => { if (state === "completed") { const savePath = item.getSavePath(); diff --git a/apps/desktop/tsconfig.node.json b/apps/desktop/tsconfig.node.json index c9de5ead8d..ff4455db14 100644 --- a/apps/desktop/tsconfig.node.json +++ b/apps/desktop/tsconfig.node.json @@ -4,6 +4,7 @@ "module": "nodenext", "moduleResolution": "NodeNext", "target": "es2022", + "lib": ["es2024"], "sourceMap": false, "typeRoots": [], "types": [], diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts index e9572047a0..f0fd2c96e6 100644 --- a/apps/desktop/vitest.config.ts +++ b/apps/desktop/vitest.config.ts @@ -5,19 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ -import { defineConfig, mergeConfig } from "vitest/config"; -import baseConfig from "@element-hq/vite-common/vite.config.js"; +import { defineProject } from "vitest/config"; -export default mergeConfig( - baseConfig, - defineConfig({ - test: { - coverage: { - // The coverage report currently chokes on this file as it doesn't process it as TypeScript - exclude: ["src/preload.cts"], - }, - include: ["src/**/*.test.ts"], - }, - }), - true, -); +export default defineProject({ + test: { + include: ["src/**/*.test.ts"], + environment: "node", + pool: "threads", + globals: false, + }, +}); diff --git a/apps/web/.eslintignore b/apps/web/.eslintignore deleted file mode 100644 index 8c739b4d77..0000000000 --- a/apps/web/.eslintignore +++ /dev/null @@ -1,18 +0,0 @@ -src/vector/modernizr.cjs -test/end-to-end-tests/node_modules/ -test/end-to-end-tests/element/ -test/end-to-end-tests/synapse/ -test/end-to-end-tests/lib/ -# Legacy skinning file that some people might still have -src/component-index.js -# Auto-generated file -src/modules.ts -src/modules.js -# Test result files -/playwright/test-results/ -/playwright/html-report/ - -# Shared components generated files -/packages/shared-components/dist/ -/packages/shared-components/src/i18n/i18nKeys.d.ts -/packages/shared-components/typedoc/ diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs deleted file mode 100644 index a5cc00f80b..0000000000 --- a/apps/web/.eslintrc.cjs +++ /dev/null @@ -1,347 +0,0 @@ -/* -Copyright 2025 Element Creations 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. -*/ - -module.exports = { - plugins: ["matrix-org", "eslint-plugin-react-compiler"], - extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"], - parserOptions: { - project: ["./tsconfig.json"], - tsconfigRootDir: __dirname, - }, - env: { - browser: true, - node: true, - }, - globals: { - LANGUAGES_FILE: "readonly", - }, - rules: { - // Things we do that break the ideal style - "no-constant-condition": "off", - "prefer-promise-reject-errors": "off", - "no-async-promise-executor": "off", - "no-extra-boolean-cast": "off", - - // Bind or arrow functions in props causes performance issues (but we - // currently use them in some places). - // It's disabled here, but we should using it sparingly. - "react/jsx-no-bind": "off", - "react/jsx-key": ["error"], - - "no-restricted-properties": [ - "error", - ...buildRestrictedPropertiesOptions( - ["window.innerHeight", "window.innerWidth", "window.visualViewport"], - "Use UIStore to access window dimensions instead.", - ), - ...buildRestrictedPropertiesOptions( - ["React.forwardRef", "*.forwardRef", "forwardRef"], - "Use ref props instead.", - ), - ...buildRestrictedPropertiesOptions( - ["*.mxcUrlToHttp", "*.getHttpUriForMxc"], - "Use Media helper instead to centralise access for customisation.", - ), - ...buildRestrictedPropertiesOptions(["window.setImmediate"], "Use setTimeout instead."), - ], - "no-restricted-globals": [ - "error", - { - name: "setImmediate", - message: "Use setTimeout instead.", - }, - { - name: "Buffer", - message: "Buffer is not available in the web.", - }, - ], - - "import/no-duplicates": ["error"], - // Ban matrix-js-sdk/src imports in favour of matrix-js-sdk/src/matrix imports to prevent unleashing hell. - // Ban compound-design-tokens raw svg imports in favour of their React component counterparts - "no-restricted-imports": [ - "error", - { - paths: [ - { - name: "react", - importNames: ["forwardRef"], - message: "Use ref props instead.", - }, - { - name: "@testing-library/react", - message: "Please use jest-matrix-react instead", - }, - { - name: "matrix-js-sdk", - message: "Please use matrix-js-sdk/src/matrix instead", - }, - { - name: "matrix-js-sdk/", - message: "Please use matrix-js-sdk/src/matrix instead", - }, - { - name: "matrix-js-sdk/src", - message: "Please use matrix-js-sdk/src/matrix instead", - }, - { - name: "matrix-js-sdk/src/", - message: "Please use matrix-js-sdk/src/matrix instead", - }, - { - name: "matrix-js-sdk/src/index", - message: "Please use matrix-js-sdk/src/matrix instead", - }, - { - name: "emojibase-regex", - message: - "This regex doesn't actually test for emoji. See the docs at https://emojibase.dev/docs/regex/ and prefer our own EMOJI_REGEX from HtmlUtils.", - }, - ], - patterns: [ - { - group: [ - "matrix-js-sdk/src/**", - "!matrix-js-sdk/src/matrix", - "!matrix-js-sdk/src/crypto-api", - "!matrix-js-sdk/src/types", - "!matrix-js-sdk/src/testing", - "!matrix-js-sdk/src/utils/**", - "matrix-js-sdk/src/utils/internal/**", - "matrix-js-sdk/lib", - "matrix-js-sdk/lib/", - "matrix-js-sdk/lib/**", - // XXX: Temporarily allow these as they are not available via the main export - "!matrix-js-sdk/src/logger", - "!matrix-js-sdk/src/errors", - "!matrix-js-sdk/src/utils", - "!matrix-js-sdk/src/version-support", - "!matrix-js-sdk/src/randomstring", - "!matrix-js-sdk/src/sliding-sync", - "!matrix-js-sdk/src/browser-index", - "!matrix-js-sdk/src/feature", - "!matrix-js-sdk/src/NamespacedValue", - "!matrix-js-sdk/src/ReEmitter", - "!matrix-js-sdk/src/event-mapper", - "!matrix-js-sdk/src/interactive-auth", - "!matrix-js-sdk/src/secret-storage", - "!matrix-js-sdk/src/room-hierarchy", - "!matrix-js-sdk/src/rendezvous", - "!matrix-js-sdk/src/indexeddb-worker", - "!matrix-js-sdk/src/pushprocessor", - "!matrix-js-sdk/src/extensible_events_v1", - "!matrix-js-sdk/src/extensible_events_v1/PollStartEvent", - "!matrix-js-sdk/src/extensible_events_v1/PollResponseEvent", - "!matrix-js-sdk/src/extensible_events_v1/PollEndEvent", - "!matrix-js-sdk/src/extensible_events_v1/InvalidEventError", - "!matrix-js-sdk/src/oidc", - "!matrix-js-sdk/src/oidc/discovery", - "!matrix-js-sdk/src/oidc/authorize", - "!matrix-js-sdk/src/oidc/validate", - "!matrix-js-sdk/src/oidc/error", - "!matrix-js-sdk/src/oidc/register", - "!matrix-js-sdk/src/webrtc", - "!matrix-js-sdk/src/webrtc/call", - "!matrix-js-sdk/src/webrtc/callFeed", - "!matrix-js-sdk/src/webrtc/mediaHandler", - "!matrix-js-sdk/src/webrtc/callEventTypes", - "!matrix-js-sdk/src/webrtc/callEventHandler", - "!matrix-js-sdk/src/webrtc/groupCallEventHandler", - "!matrix-js-sdk/src/models", - "!matrix-js-sdk/src/models/read-receipt", - "!matrix-js-sdk/src/models/relations-container", - "!matrix-js-sdk/src/models/related-relations", - "!matrix-js-sdk/src/matrixrtc", - ], - message: "Please use matrix-js-sdk/src/matrix instead", - }, - { - group: ["emojibase-regex/emoji*"], - message: - "This regex doesn't actually test for emoji. See the docs at https://emojibase.dev/docs/regex/ and prefer our own EMOJI_REGEX from HtmlUtils.", - }, - { - group: ["@vector-im/compound-design-tokens/icons/*"], - message: "Please use @vector-im/compound-design-tokens/assets/web/icons/* instead", - }, - { - group: ["**/packages/shared-components/**", "../packages/shared-components/**"], - message: "Please use @element-hq/web-shared-components", - }, - ], - }, - ], - - // There are too many a11y violations to fix at once - // Turn violated rules off until they are fixed - "jsx-a11y/aria-activedescendant-has-tabindex": "off", - "jsx-a11y/click-events-have-key-events": "off", - "jsx-a11y/interactive-supports-focus": "off", - "jsx-a11y/media-has-caption": "off", - "jsx-a11y/mouse-events-have-key-events": "off", - "jsx-a11y/no-autofocus": "off", - "jsx-a11y/no-noninteractive-element-interactions": "off", - "jsx-a11y/no-noninteractive-element-to-interactive-role": "off", - "jsx-a11y/no-noninteractive-tabindex": "off", - "jsx-a11y/no-static-element-interactions": "off", - "jsx-a11y/role-supports-aria-props": "off", - - "matrix-org/require-copyright-header": "error", - - "react-compiler/react-compiler": "error", - }, - overrides: [ - { - files: ["src/**/*.{ts,tsx}", "test/**/*.{ts,tsx}", "playwright/**/*.ts", "*.ts"], - extends: ["plugin:matrix-org/typescript", "plugin:matrix-org/react"], - rules: { - "@typescript-eslint/unbound-method": ["error", { ignoreStatic: true }], - "@typescript-eslint/explicit-function-return-type": [ - "error", - { - allowExpressions: true, - }, - ], - - // Things we do that break the ideal style - "prefer-promise-reject-errors": "off", - "no-extra-boolean-cast": "off", - - // Remove Babel things manually due to override limitations - "@babel/no-invalid-this": ["off"], - - // We're okay being explicit at the moment - "@typescript-eslint/no-empty-interface": "off", - // We disable this while we're transitioning - "@typescript-eslint/no-explicit-any": "off", - // We'd rather not do this but we do - "@typescript-eslint/ban-ts-comment": "off", - // We're okay with assertion errors when we ask for them - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-empty-object-type": [ - "error", - { - // We do this sometimes to brand interfaces - allowInterfaces: "with-single-extends", - }, - ], - }, - }, - { - files: ["test/**/*.{ts,tsx}", "playwright/**/*.ts"], - extends: ["plugin:matrix-org/jest"], - rules: { - // We don't need super strict typing in test utilities - "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/explicit-member-accessibility": "off", - "@typescript-eslint/no-empty-object-type": "off", - "@typescript-eslint/unbound-method": "off", - - // Jest/Playwright specific - - // Disabled tests are a reality for now but as soon as all of the xits are - // eliminated, we should enforce this. - "jest/no-disabled-tests": "off", - // Also treat "oldBackendOnly" as a test function. - // Used in some crypto tests. - "jest/no-standalone-expect": [ - "error", - { - additionalTestBlockFunctions: ["beforeAll", "beforeEach", "oldBackendOnly"], - }, - ], - - // These are fine in tests - "no-restricted-globals": "off", - "react-compiler/react-compiler": "off", - }, - }, - { - files: ["playwright/**/*.ts"], - parserOptions: { - project: ["./playwright/tsconfig.json"], - }, - rules: { - "react-hooks/rules-of-hooks": ["off"], - "@typescript-eslint/no-floating-promises": ["error"], - }, - }, - { - files: ["module_system/**/*.{ts,tsx}"], - parserOptions: { - project: ["./tsconfig.module_system.json"], - }, - extends: ["plugin:matrix-org/typescript", "plugin:matrix-org/react"], - // NOTE: These rules are frozen and new rules should not be added here. - // New changes belong in https://github.com/matrix-org/eslint-plugin-matrix-org/ - rules: { - // Things we do that break the ideal style - "prefer-promise-reject-errors": "off", - "quotes": "off", - - // We disable this while we're transitioning - "@typescript-eslint/no-explicit-any": "off", - // We're okay with assertion errors when we ask for them - "@typescript-eslint/no-non-null-assertion": "off", - - // Ban matrix-js-sdk/src imports in favour of matrix-js-sdk/src/matrix imports to prevent unleashing hell. - "no-restricted-imports": [ - "error", - { - paths: [ - { - name: "matrix-js-sdk", - message: "Please use matrix-js-sdk/src/matrix instead", - }, - { - name: "matrix-js-sdk/", - message: "Please use matrix-js-sdk/src/matrix instead", - }, - { - name: "matrix-js-sdk/src", - message: "Please use matrix-js-sdk/src/matrix instead", - }, - { - name: "matrix-js-sdk/src/", - message: "Please use matrix-js-sdk/src/matrix instead", - }, - { - name: "matrix-js-sdk/src/index", - message: "Please use matrix-js-sdk/src/matrix instead", - }, - ], - patterns: [ - { - group: ["matrix-js-sdk/lib", "matrix-js-sdk/lib/", "matrix-js-sdk/lib/**"], - message: "Please use matrix-js-sdk/src/* instead", - }, - ], - }, - ], - }, - }, - ], - settings: { - react: { - version: "detect", - }, - }, -}; - -function buildRestrictedPropertiesOptions(properties, message) { - return properties.map((prop) => { - let [object, property] = prop.split("."); - if (object === "*") { - object = undefined; - } - return { - object, - property, - message, - }; - }); -} diff --git a/apps/web/.lintstagedrc b/apps/web/.lintstagedrc index eaabd3138b..ae8a7fc7e9 100644 --- a/apps/web/.lintstagedrc +++ b/apps/web/.lintstagedrc @@ -1,7 +1,5 @@ { - "*": "prettier --write --ignore-unknown", - "src/**/*.(ts|tsx)": ["eslint --fix"], - "scripts/**/*.(ts|tsx)": ["eslint --fix"], - "module_system/**/*.(ts|tsx)": ["eslint --fix"], + "*": "oxfmt --no-error-on-unmatched-pattern", + "*.{js,jsx,ts,tsx,mjs,cjs}": "oxlint --no-error-on-unmatched-pattern", "*.pcss": ["stylelint --fix"] } diff --git a/apps/web/.stylelintrc.cjs b/apps/web/.stylelintrc.cjs index 2ccb2abf75..4388f7919d 100644 --- a/apps/web/.stylelintrc.cjs +++ b/apps/web/.stylelintrc.cjs @@ -1,3 +1,10 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + module.exports = { extends: ["stylelint-config-standard"], customSyntax: "postcss-scss", @@ -62,10 +69,10 @@ module.exports = { { from: "res/css/views/settings/tabs/_SettingsTab.pcss", type: "css" }, { from: "res/css/structures/_RoomView.pcss", type: "css" }, // Compound vars - "../../node_modules/@vector-im/compound-design-tokens/assets/web/css/cpd-common-base.css", - "../../node_modules/@vector-im/compound-design-tokens/assets/web/css/cpd-common-semantic.css", - "../../node_modules/@vector-im/compound-design-tokens/assets/web/css/cpd-theme-light-base-mq.css", - "../../node_modules/@vector-im/compound-design-tokens/assets/web/css/cpd-theme-light-semantic-mq.css", + "./node_modules/@vector-im/compound-design-tokens/assets/web/css/cpd-common-base.css", + "./node_modules/@vector-im/compound-design-tokens/assets/web/css/cpd-common-semantic.css", + "./node_modules/@vector-im/compound-design-tokens/assets/web/css/cpd-theme-light-base-mq.css", + "./node_modules/@vector-im/compound-design-tokens/assets/web/css/cpd-theme-light-semantic-mq.css", ], }, ], diff --git a/apps/web/@types/url-preview.ts b/apps/web/@types/url-preview.ts new file mode 100644 index 0000000000..761f4c33bc --- /dev/null +++ b/apps/web/@types/url-preview.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { + type EncryptedFile, + type RoomMessageEventContent as SdkRoomMessageEventContent, +} from "matrix-js-sdk/src/types"; + +/** + * Bundled URL previews in MSC-4095 + * + * @see https://github.com/matrix-org/matrix-spec-proposals/pull/4095 + */ +interface UnstableBundledUrlPreviews { + "com.beeper.linkpreviews"?: UnstableBundledUrlPreviewSingle[]; +} + +/** + * Single item in bundled URL previews in MSC4095 + * + * @see https://github.com/matrix-org/matrix-spec-proposals/pull/4095 + */ +export interface UnstableBundledUrlPreviewSingle { + "matched_url": string; + "beeper:image:encryption"?: EncryptedFile; + "matrix:image:size"?: number; + "og:image"?: string; + "og:url"?: string; + "og:image:width"?: number; + "og:image:height"?: number; + "og:image:type"?: string; + "og:title"?: string; + "og:description"?: string; +} + +export type RoomMessageEventContent = SdkRoomMessageEventContent & UnstableBundledUrlPreviews; diff --git a/apps/web/@types/webpack-version-file-plugin.d.ts b/apps/web/@types/webpack-version-file-plugin.d.ts index 7869805f83..95432bdb1c 100644 --- a/apps/web/@types/webpack-version-file-plugin.d.ts +++ b/apps/web/@types/webpack-version-file-plugin.d.ts @@ -13,6 +13,7 @@ declare module "webpack-version-file-plugin" { extras?: Record; } + // oxlint-disable-next-line typescript/no-extraneous-class export default class VersionFilePlugin { public constructor(opts: Opts); } diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 8c0049fb07..67f7bd8450 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -1,8 +1,14 @@ -# syntax=docker.io/docker/dockerfile:1.23-labs@sha256:7eca9451d94f9b8ad22e44988b92d595d3e4d65163794237949a8c3413fbed5d +# syntax=docker.io/docker/dockerfile:1.25-labs@sha256:4426b5e269e36911b94fb79cf67f1fd7155ef11b2bbc8ab23cbfcbc97130efe9 # Context must be the root of the monorepo +# PNPM source +FROM --platform=$BUILDPLATFORM ghcr.io/pnpm/pnpm:11.10.0@sha256:9a6eb06d5f861d830fe27d85a91415e60527fa45ec45b52ee43c92a8aaf3bf8a AS pnpm + # Builder -FROM --platform=$BUILDPLATFORM node:24-bullseye@sha256:2c00db8852d28215c6203fafe9f05046acd9fdd48bcfc42467f4cba39b42dab4 AS builder +FROM --platform=$BUILDPLATFORM node:24-bullseye@sha256:f7250178a8fcdde6e7340c7f3945e0c5cbcc10bedddc6bcf61edb39ce8f390d2 AS builder + +COPY --from=pnpm /opt/pnpm /opt/pnpm +RUN ln -s /opt/pnpm/pnpm /usr/local/bin/pnpm # Support custom branch of the js-sdk. This also helps us build images of element-web develop. ARG USE_CUSTOM_SDKS=false @@ -13,9 +19,8 @@ WORKDIR /src # Install dependencies COPY --parents package.json pnpm-lock.yaml pnpm-workspace.yaml patches scripts **/package.json /src/ -RUN corepack enable -RUN --mount=type=bind,source=.git,target=/src/.git /src/scripts/docker-link-repos.sh RUN pnpm install --frozen-lockfile +RUN --mount=type=bind,source=.git,target=/src/.git /src/scripts/docker-link-repos.sh # Build COPY --link --exclude=.git --exclude=apps/web/docker . /src @@ -25,7 +30,7 @@ RUN --mount=type=bind,source=.git,target=/src/.git /src/scripts/docker-package.s RUN cp /src/apps/web/config.sample.json /src/apps/web/webapp/config.json # App -FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:1df9285ed5bdaaad9ca503ac608e12fe1ba93136bb249fe976477989c1db4ede +FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:22f839c5fb4007dc24d203a170a9e03fc185d660bfefc34ac6823a7aef085cbc AS element_web # Need root user to install packages & manipulate the usr directory USER root @@ -50,3 +55,39 @@ USER nginx ENV ELEMENT_WEB_PORT=80 HEALTHCHECK --start-period=5s CMD wget -q --spider http://localhost:$ELEMENT_WEB_PORT/config.json + +# Modules are consumed as prebuilt release artifacts rather than built from source. +# Each module is pinned to a version and the sha256 of its release archive. +FROM --platform=$BUILDPLATFORM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce AS modules + +ARG MODULE_BANNER_VERSION=v1.0.0 +ADD --checksum=sha256:8aabd9d43d40ffb499050246f7968323a08895b6f53bd48a71d4c7f0daf96634 \ + https://github.com/element-hq/element-web/releases/download/module%2Fbanner%2F${MODULE_BANNER_VERSION}/banner-${MODULE_BANNER_VERSION}.zip \ + /tmp/modules/banner.zip + +ARG MODULE_RESTRICTED_GUESTS_VERSION=v1.0.0 +ADD --checksum=sha256:d883327469ae78504a4e0aa8ebf2bbf525a9407ab10430ef42cf9338cc0821bb \ + https://github.com/element-hq/element-web/releases/download/module%2Frestricted-guests%2F${MODULE_RESTRICTED_GUESTS_VERSION}/restricted-guests-${MODULE_RESTRICTED_GUESTS_VERSION}.zip \ + /tmp/modules/restricted-guests.zip + +ARG MODULE_WIDGET_LIFECYCLE_VERSION=v1.0.0 +ADD --checksum=sha256:125e5a7a045e3cebee2c82ca30a477ebc8e31ee3bd139ae46e177612c25cc988 \ + https://github.com/element-hq/element-web/releases/download/module%2Fwidget-lifecycle%2F${MODULE_WIDGET_LIFECYCLE_VERSION}/widget-lifecycle-${MODULE_WIDGET_LIFECYCLE_VERSION}.zip \ + /tmp/modules/widget-lifecycle.zip + +ARG MODULE_WIDGET_TOGGLES_VERSION=v1.0.0 +ADD --checksum=sha256:27b0d0d9d803c41855aa94f02493ce214321f8d0c65af500d85875a3ef20efb0 \ + https://github.com/element-hq/element-web/releases/download/module%2Fwidget-toggles%2F${MODULE_WIDGET_TOGGLES_VERSION}/widget-toggles-${MODULE_WIDGET_TOGGLES_VERSION}.zip \ + /tmp/modules/widget-toggles.zip + +# Unpack the modules +RUN apk add --no-cache unzip && \ + for archive in /tmp/modules/*.zip; do \ + name=$(basename "$archive" .zip); \ + mkdir -p "/modules/$name" && unzip -q "$archive" -d "/modules/$name"; \ + done + +# Target with element_web + `/modules` copied in +FROM element_web AS element_web_modules + +COPY --from=modules /modules /modules diff --git a/apps/web/I18nWebpackPlugin.ts b/apps/web/I18nWebpackPlugin.ts index 732f03dda1..10649e47f6 100644 --- a/apps/web/I18nWebpackPlugin.ts +++ b/apps/web/I18nWebpackPlugin.ts @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. import webpack from "webpack"; import * as fs from "node:fs/promises"; -import * as path from "node:path"; +import path from "node:path"; import _ from "lodash"; import { type Translations } from "matrix-web-i18n"; @@ -62,7 +62,7 @@ export class I18nWebpackPlugin { } const primaryPath = paths[0]; - const includeLangs = [...new Set([...(await fs.readdir(primaryPath))])] + const includeLangs = [...new Set(await fs.readdir(primaryPath))] .filter((fn) => fn.endsWith(".json")) .map((f) => f.slice(0, -5)); diff --git a/apps/web/README.md b/apps/web/README.md index c0683d7017..c4b84a1268 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -126,5 +126,5 @@ Element Web supports a module system that allows you to extend or modify functio Modules are extensions that can add or modify Element Web's functionality. They are: -- Built using the [`@element-hq/element-web-module-api`](https://github.com/element-hq/element-modules/tree/main/packages/element-web-module-api) +- Built using the [`@element-hq/element-web-module-api`](https://github.com/element-hq/element-web/tree/develop/packages/module-api) - Loaded in EW via [config.json](../../docs/config.md#modules) diff --git a/apps/web/__mocks__/cssMock.js b/apps/web/__mocks__/cssMock.js index 9b5d9b3476..a85e0e6eb1 100644 --- a/apps/web/__mocks__/cssMock.js +++ b/apps/web/__mocks__/cssMock.js @@ -1 +1,8 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + module.exports = "css-file-stub"; diff --git a/apps/web/__mocks__/empty.js b/apps/web/__mocks__/empty.js index 51fb4fe937..ef57358c34 100644 --- a/apps/web/__mocks__/empty.js +++ b/apps/web/__mocks__/empty.js @@ -1,2 +1,9 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + // Yes, this is empty. module.exports = {}; diff --git a/apps/web/__mocks__/imageMock.js b/apps/web/__mocks__/imageMock.js index 474ac702b4..2d381a609d 100644 --- a/apps/web/__mocks__/imageMock.js +++ b/apps/web/__mocks__/imageMock.js @@ -1 +1,8 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + module.exports = "image-file-stub"; diff --git a/apps/web/__mocks__/languages.json b/apps/web/__mocks__/languages.json deleted file mode 100644 index 35a400808b..0000000000 --- a/apps/web/__mocks__/languages.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "en": "en_EN.json", - "en-us": "en_US.json" -} diff --git a/apps/web/__mocks__/maplibre-gl.js b/apps/web/__mocks__/maplibre-gl.js index 475648e774..a76c529e2f 100644 --- a/apps/web/__mocks__/maplibre-gl.js +++ b/apps/web/__mocks__/maplibre-gl.js @@ -6,7 +6,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. */ -const EventEmitter = require("events"); +const EventEmitter = require("node:events"); const { LngLat, NavigationControl, LngLatBounds } = require("maplibre-gl"); class MockMap extends EventEmitter { diff --git a/apps/web/__mocks__/svg-react.js b/apps/web/__mocks__/svg-react.js new file mode 100644 index 0000000000..16ec267a35 --- /dev/null +++ b/apps/web/__mocks__/svg-react.js @@ -0,0 +1,8 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +export default "div"; diff --git a/apps/web/__mocks__/svg.js b/apps/web/__mocks__/svg.js index ee2ab11a01..bc2b312afe 100644 --- a/apps/web/__mocks__/svg.js +++ b/apps/web/__mocks__/svg.js @@ -1,2 +1,8 @@ -export const Icon = "div"; +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + export default "image-file-stub"; diff --git a/apps/web/test/test-utils/oidc.ts b/apps/web/__mocks__/workerFactoryMock-jest.js similarity index 71% rename from apps/web/test/test-utils/oidc.ts rename to apps/web/__mocks__/workerFactoryMock-jest.js index c8032551b0..a6f04ce086 100644 --- a/apps/web/test/test-utils/oidc.ts +++ b/apps/web/__mocks__/workerFactoryMock-jest.js @@ -6,4 +6,6 @@ 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. */ -export { makeDelegatedAuthConfig, mockOpenIdConfiguration } from "matrix-js-sdk/src/testing"; +export default function workerFactory(options) { + return jest.fn; +} diff --git a/apps/web/__mocks__/workerFactoryMock.js b/apps/web/__mocks__/workerFactoryMock.js index a6f04ce086..e637f835c2 100644 --- a/apps/web/__mocks__/workerFactoryMock.js +++ b/apps/web/__mocks__/workerFactoryMock.js @@ -6,6 +6,10 @@ 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 { vi } from "vitest"; + export default function workerFactory(options) { - return jest.fn; + return { + postMessage: vi.fn(), + }; } diff --git a/apps/web/babel.config.cjs b/apps/web/babel.config.cjs index 58df067b79..ee3c95ccd0 100644 --- a/apps/web/babel.config.cjs +++ b/apps/web/babel.config.cjs @@ -1,3 +1,10 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + module.exports = { sourceMaps: true, presets: [ diff --git a/apps/web/config.sample.json b/apps/web/config.sample.json index 24b087f43e..44efbee7f9 100644 --- a/apps/web/config.sample.json +++ b/apps/web/config.sample.json @@ -42,8 +42,9 @@ "preferred_domain": "meet.element.io" }, "element_call": { - "url": "https://call.element.io", - "brand": "Element Call" + "brand": "Element Call", + "disable": false, + "use_exclusively": false }, "map_style_url": "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx" } diff --git a/apps/web/element.io/app/config.json b/apps/web/element.io/app/config.json index 032712a5d8..c1e6158007 100644 --- a/apps/web/element.io/app/config.json +++ b/apps/web/element.io/app/config.json @@ -47,10 +47,6 @@ }, "features": { "feature_video_rooms": true, - "feature_group_calls": true, "feature_element_call_video_rooms": true - }, - "element_call": { - "url": "https://call.element.io" } } diff --git a/apps/web/element.io/develop/config.json b/apps/web/element.io/develop/config.json index 2db410355c..2539a1e8f1 100644 --- a/apps/web/element.io/develop/config.json +++ b/apps/web/element.io/develop/config.json @@ -48,15 +48,11 @@ "features": { "threadsActivityCentre": true, "feature_video_rooms": true, - "feature_group_calls": true, "feature_element_call_video_rooms": true }, "setting_defaults": { "RustCrypto.staged_rollout_percent": 100, "Registration.mobileRegistrationHelper": true }, - "element_call": { - "url": "https://call.element.dev" - }, "map_style_url": "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx" } diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index c1a8b6ad23..83b3cf7479 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -6,10 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { env } from "process"; +import { env } from "node:process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import type { Config } from "jest"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const config: Config = { testEnvironment: "jest-fixed-jsdom", testEnvironmentOptions: { @@ -27,38 +31,46 @@ const config: Config = { moduleNameMapper: { // Support CSS module "\\.(module.css)$": "identity-obj-proxy", - "\\.(css|scss|pcss)$": "/__mocks__/cssMock.js", + "\\.(css|scss|pcss)(\\?raw)?$": "/__mocks__/cssMock.js", "\\.(gif|png|ttf|woff2)$": "/__mocks__/imageMock.js", "\\.svg$": "/__mocks__/svg.js", - "\\$webapp/i18n/languages.json": "/__mocks__/languages.json", - "^react$": "/../../node_modules/react", - "^react-dom$": "/../../node_modules/react-dom", - "^matrix-js-sdk$": "/../../node_modules/matrix-js-sdk/src", - "^matrix-react-sdk$": "/src", + "\\.svg\\?react$": "/__mocks__/svg-react.js", + "^matrix-js-sdk(.*)$": "/node_modules/matrix-js-sdk$1", + "^react$": "/node_modules/react", + "^react-dom$": "/node_modules/react-dom", "decoderWorker\\.min\\.js": "/__mocks__/empty.js", "decoderWorker\\.min\\.wasm": "/__mocks__/empty.js", "waveWorker\\.min\\.js": "/__mocks__/empty.js", "context-filter-polyfill": "/__mocks__/empty.js", - "workers/(.+)Factory": "/__mocks__/workerFactoryMock.js", - "^!!raw-loader!.*": "jest-raw-loader", + "workers/(.+)Factory": "/__mocks__/workerFactoryMock-jest.js", + ".*\\?raw": "jest-raw-loader", "recorderWorkletFactory": "/__mocks__/empty.js", - "@vector-im/compound-web": "/../../node_modules/@vector-im/compound-web", + "@vector-im/compound-web": "/node_modules/@vector-im/compound-web", + "^vitest$": "/__mocks__/empty.js", + "jest-mock-vitest-adapter": "/test/setup/adapter.ts", + "test-utils-rtl": "/test/test-utils/jest-matrix-react.tsx", }, transformIgnorePatterns: [ - "/node_modules/(?!(mime|matrix-js-sdk|uuid|p-retry|is-network-error|react-merge-refs|is-ip|ip-regex|super-regex|function-timeout|time-span|convert-hrtime|clone-regexp|is-regexp|matrix-web-i18n|await-lock|@element-hq/web-shared-components|react-virtuoso|lodash|domutils|domhandler|domelementtype|dom-serializer|entities)).+$", + `${path.join(__dirname, "../..")}/node_modules/.pnpm/(?!(matrix-js-sdk|htmlparser2|mime|uuid|p-retry|is-network-error|react-merge-refs|is-ip|ip-regex|super-regex|function-timeout|time-span|convert-hrtime|clone-regexp|is-regexp|matrix-web-i18n|await-lock|@element-hq/web-shared-components|react-virtuoso|lodash|domutils|domhandler|domelementtype|dom-serializer|entities)).+$`, ], collectCoverageFrom: [ "/src/**/*.{js,ts,tsx}", - "/packages/**/*.{js,ts,tsx}", // getSessionLock is piped into a different JS context via stringification, and the coverage functionality is // not available in that contest. So, turn off coverage instrumentation for it. "!/src/utils/SessionLock.ts", // Coverage chokes on type definition files "!/src/**/*.d.ts", + // Ignore vitest tests + "!/src/**/*.test.{ts,tsx}", + "!/src/test/**", + // Exclude mocks + "!/src/**/*-{mock,mocks}.{ts,tsx}", ], - coverageReporters: ["text-summary", "lcov"], + coverageReporters: ["text-summary", ["lcov", { projectRoot: "../../" }]], prettierPath: null, moduleDirectories: ["node_modules", "test/test-utils"], + workerIdleMemoryLimit: "512MB", + snapshotSerializers: ["/src/test/react-use-id-serializer.ts"], }; // if we're running under GHA, enable relevant reporters @@ -66,7 +78,6 @@ if (env["GITHUB_ACTIONS"] !== undefined) { config.reporters ??= []; config.reporters.push(["github-actions", { silent: false }]); config.reporters.push("summary"); - config.reporters.push("@casualbot/jest-sonar-reporter"); // if we're running against the develop branch, also enable the slow test reporter if (env["GITHUB_REF"] == "refs/heads/develop") { diff --git a/apps/web/module_system/installer.ts b/apps/web/module_system/installer.ts index e2da8eb229..b179f394ca 100644 --- a/apps/web/module_system/installer.ts +++ b/apps/web/module_system/installer.ts @@ -134,9 +134,9 @@ function getOptionalDepNames(pkgJsonStr: string): string[] { function findDepVersionInPackageJson(dep: string, pkgJsonStr: string): string { const pkgJson = JSON.parse(pkgJsonStr); const packages = { - ...(pkgJson["optionalDependencies"] ?? {}), - ...(pkgJson["devDependencies"] ?? {}), - ...(pkgJson["dependencies"] ?? {}), + ...pkgJson["optionalDependencies"], + ...pkgJson["devDependencies"], + ...pkgJson["dependencies"], }; return packages[dep]; } diff --git a/apps/web/package.json b/apps/web/package.json index 3e3802e918..7cd4ce2229 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "element-web", - "version": "1.12.17", + "version": "1.12.26", "description": "Element: the future of secure communication", "author": "New Vector Ltd.", "repository": { @@ -15,7 +15,7 @@ "scripts": { "i18n": "matrix-gen-i18n src res && pnpm i18n:sort && pnpm i18n:lint", "i18n:sort": "matrix-sort-i18n src/i18n/strings/en_EN.json", - "i18n:lint": "matrix-i18n-lint && prettier --log-level=silent --write src/i18n/strings/ --ignore-path /dev/null", + "i18n:lint": "matrix-i18n-lint && oxfmt src/i18n/strings/", "i18n:diff": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && pnpm i18n && matrix-compare-i18n-files src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json", "rethemendex": "sh ./res/css/rethemendex.sh", "build": "nx build", @@ -24,16 +24,16 @@ "vendor:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js", "dist": "./scripts/package.sh", "start": "nx start", - "lint": "pnpm lint:types && pnpm lint:js && pnpm lint:style", - "lint:js": "eslint --max-warnings 0 src test playwright module_system", - "lint:js-fix": "eslint --fix src test playwright module_system", + "lint": "pnpm lint:types && pnpm lint:style", "lint:types": "nx lint:types", "lint:style": "stylelint \"res/css/**/*.pcss\"", "test": "nx test:unit", + "test:vitest": "nx test:vitest", "test:playwright": "nx test:playwright --", "test:playwright:open": "nx test:playwright -- --ui", "test:playwright:screenshots": "nx test:playwright:screenshots --", "coverage": "pnpm test --coverage", + "coverage:diff": "diff-cover --config-file ../../diff-cover.toml coverage/lcov.info", "analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp" }, "dependencies": { @@ -43,17 +43,17 @@ "@fontsource/fira-code": "^5", "@fontsource/inter": "catalog:", "@formatjs/intl-segmenter": "^12.0.0", - "@matrix-org/analytics-events": "^0.33.0", - "@matrix-org/emojibase-bindings": "^1.5.0", + "@matrix-org/analytics-events": "^0.37.0", + "@matrix-org/emojibase-bindings": "catalog:", "@matrix-org/react-sdk-module-api": "^2.4.0", "@sentry/browser": "^10.0.0", "@types/png-chunks-extract": "^1.0.2", "@vector-im/compound-design-tokens": "catalog:", "@vector-im/compound-web": "catalog:", "@vector-im/matrix-wysiwyg": "2.40.0", - "@zxcvbn-ts/core": "^3.0.4", - "@zxcvbn-ts/language-common": "^3.0.4", - "@zxcvbn-ts/language-en": "^3.0.2", + "@zxcvbn-ts/core": "^4.0.0", + "@zxcvbn-ts/language-common": "^4.0.0", + "@zxcvbn-ts/language-en": "^4.0.0", "await-lock": "^3.0.0", "bloom-filters": "^3.0.3", "blurhash": "^2.0.3", @@ -66,30 +66,29 @@ "domutils": "^4.0.0", "emojibase-regex": "^17.0.0", "escape-html": "^1.0.3", + "events": "^3.3.0", "file-saver": "^2.0.5", - "filesize": "11.0.17", + "filesize": "11.0.22", "github-markdown-css": "^5.5.1", "glob-to-regexp": "^0.4.1", "highlight.js": "^11.3.1", "html-entities": "^2.0.0", "html-react-parser": "^6.0.0", "is-ip": "^5.0.0", - "js-xxhash": "^5.0.0", "jsrsasign": "^11.0.0", "jszip": "^3.7.0", - "katex": "^0.16.0", + "katex": "^0.18.0", "lodash": "npm:lodash-es@4.18.1", "maplibre-gl": "^5.0.0", "matrix-encrypt-attachment": "^1.0.3", - "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#d19cb751da8bbdc75c19db751fff25f21761d23a", - "matrix-widget-api": "^1.16.1", + "matrix-js-sdk": "42.2.0", + "matrix-widget-api": "^1.18.0", "memoize-one": "^6.0.0", "mime": "^4.0.4", - "oidc-client-ts": "^3.0.1", "opus-recorder": "^8.0.3", - "pako": "^2.0.3", + "pako": "^3.0.0", "png-chunks-extract": "^1.0.0", - "posthog-js": "1.372.8", + "posthog-js": "1.407.2", "qrcode": "1.5.4", "re-resizable": "6.11.2", "react": "catalog:", @@ -101,15 +100,13 @@ "react-transition-group": "^4.4.1", "rfc4648": "^1.4.0", "sanitize-filename": "^1.6.3", - "sanitize-html": "2.17.3", + "sanitize-html": "2.17.6", "tar-js": "^0.3.0", "ua-parser-js": "1.0.40", "what-input": "^5.2.10" }, "devDependencies": { "@babel/core": "^7.12.10", - "@babel/eslint-parser": "^7.12.10", - "@babel/eslint-plugin": "^7.12.10", "@babel/plugin-proposal-decorators": "^7.25.9", "@babel/plugin-proposal-export-default-from": "^7.12.1", "@babel/plugin-syntax-dynamic-import": "^7.8.3", @@ -128,19 +125,18 @@ "@sorb/threadnet-call-embedded": "0.19.2-threadnet.12", "@element-hq/element-web-playwright-common": "workspace:*", "@fetch-mock/jest": "^0.2.20", + "@fetch-mock/vitest": "^0.2.18", "@jest/globals": "^30.2.0", "@peculiar/webcrypto": "^1.4.3", "@playwright/test": "catalog:", "@principalstudio/html-webpack-inject-preload": "^1.2.7", "@sentry/webpack-plugin": "^5.0.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/jest-dom": "^7.0.0", "@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/css-tree": "^2.3.8", "@types/diff-match-patch": "^1.0.32", "@types/escape-html": "^1.0.1", @@ -152,8 +148,7 @@ "@types/jsrsasign": "^10.5.4", "@types/lodash": "^4.14.168", "@types/modernizr": "^3.5.3", - "@types/node": "22", - "@types/pako": "^2.0.0", + "@types/node": "catalog:", "@types/postcss-import": "^14.0.3", "@types/qrcode": "^1.3.5", "@types/react": "catalog:", @@ -161,12 +156,12 @@ "@types/react-dom": "catalog:", "@types/react-transition-group": "^4.4.0", "@types/sanitize-html": "2.16.1", - "@types/sdp-transform": "^2.4.10", + "@types/sdp-transform": "^3.0.0", "@types/semver": "^7.5.8", "@types/tar-js": "^0.3.5", "@types/ua-parser-js": "^0.7.36", - "@typescript-eslint/eslint-plugin": "^8.19.0", - "@typescript-eslint/parser": "^8.19.0", + "@typescript/native": "catalog:", + "@vitest/spy": "catalog:", "babel-jest": "^30.0.0", "babel-loader": "^10.0.0", "babel-plugin-jsx-remove-data-test-id": "^3.0.0", @@ -175,21 +170,10 @@ "css-loader": "^7.0.0", "css-minimizer-webpack-plugin": "^8.0.0", "dotenv": "^17.0.0", - "eslint": "8.57.1", - "eslint-config-google": "^0.14.0", - "eslint-config-prettier": "^10.0.0", - "eslint-plugin-deprecate": "0.9.0", - "eslint-plugin-import": "^2.25.4", - "eslint-plugin-jest": "^29.0.0", - "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-matrix-org": "^3.0.0", - "eslint-plugin-react": "^7.28.0", - "eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124", - "eslint-plugin-react-hooks": "^7.0.0", - "eslint-plugin-unicorn": "^56.0.0", "express": "^5.0.0", "fake-indexeddb": "^6.0.0", "file-loader": "^6.0.0", + "happy-dom": "^20.10.2", "html-webpack-plugin": "^5.5.3", "identity-obj-proxy": "^3.0.0", "jest": "^30.0.0", @@ -202,47 +186,44 @@ "matrix-web-i18n": "catalog:", "mini-css-extract-plugin": "2.10.2", "modernizr": "^3.12.0", + "oxfmt": "0.60.0", "playwright-core": "catalog:", - "postcss": "8.5.14", + "postcss": "8.5.25", "postcss-easings": "4.0.0", "postcss-hexrgba": "2.1.0", "postcss-import": "16.1.1", "postcss-loader": "8.2.1", "postcss-mixins": "12.1.2", - "postcss-nested": "7.0.2", - "postcss-preset-env": "11.2.1", + "postcss-nested": "8.0.1", + "postcss-preset-env": "11.3.2", "postcss-scss": "4.0.9", "postcss-simple-vars": "7.0.1", - "prettier": "3.8.3", "process": "^0.11.10", - "raw-loader": "^4.0.2", "semver": "^7.5.2", + "shared-types": "workspace:*", "source-map-loader": "^5.0.0", "stylelint": "^17.0.0", "stylelint-config-standard": "^40.0.0", "stylelint-scss": "^7.0.0", "stylelint-value-no-unknown-custom-properties": "^6.0.1", "terser-webpack-plugin": "^5.3.9", - "testcontainers": "^11.0.0", - "typescript": "catalog:", + "testcontainers": "^12.0.0", + "typescript": "catalog:ts6", "util": "^0.12.5", + "vite-plugin-svgr": "catalog:", + "vitest": "catalog:", + "vitest-canvas-mock": "^1.1.4", "web-streams-polyfill": "^4.0.0", "webpack": "^5.89.0", "webpack-bundle-analyzer": "^5.0.0", "webpack-cli": "^7.0.0", - "webpack-dev-server": "^5.0.0", + "webpack-dev-server": "^6.0.0", "webpack-retry-chunk-load-plugin": "^3.1.1", "webpack-version-file-plugin": "^0.5.0", "yaml": "^2.3.3" }, - "@casualbot/jest-sonar-reporter": { - "outputDirectory": "coverage", - "outputName": "jest-sonar-report.xml", - "relativePaths": true - }, "engines": { "node": ">=22.18" }, - "packageManager": "pnpm@10.33.3+sha512.a19744364a7e248b92657a4ca5973f9354d21caf982579674b1c539f32c7420c47138ad8b1254df07aba9bc782d9b3029e3db34d5dbff974326eb74dac8ff489", "private": true } diff --git a/apps/web/playwright/e2e/accessibility/keyboard-navigation.spec.ts b/apps/web/playwright/e2e/accessibility/keyboard-navigation.spec.ts index a43a4e07b4..edd7b37ea8 100644 --- a/apps/web/playwright/e2e/accessibility/keyboard-navigation.spec.ts +++ b/apps/web/playwright/e2e/accessibility/keyboard-navigation.spec.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. */ +import { rejectToast } from "@element-hq/element-web-playwright-common"; + import { test, expect } from "../../element-web-test"; import { Bot } from "../../pages/bot"; @@ -15,7 +17,7 @@ test.describe("Landmark navigation tests", () => { }); test("without any rooms", async ({ page, homeserver, app, user }) => { - await app.closeVerifyToast(); + await rejectToast(page, "Verify this device"); // sometimes the space button doesn't appear right away await expect(page.locator(".mx_SpaceButton_active")).toBeVisible(); @@ -63,7 +65,7 @@ test.describe("Landmark navigation tests", () => { await cli.invite(bobRoom.room_id, bob); }, { - bob: bob.credentials.userId, + bob: bob.credentials!.userId, }, ); @@ -116,11 +118,11 @@ test.describe("Landmark navigation tests", () => { await cli.invite(bobRoom.room_id, bob); }, { - bob: bob.credentials.userId, + bob: bob.credentials!.userId, }, ); - await app.closeVerifyToast(); + await rejectToast(page, "Verify this device"); await app.viewRoomByName("Bob"); // confirm the room was loaded await expect(page.getByText("Bob joined the room")).toBeVisible(); diff --git a/apps/web/playwright/e2e/audio-player/audio-player.spec.ts b/apps/web/playwright/e2e/audio-player/audio-player.spec.ts index 8223996a61..e20791ae94 100644 --- a/apps/web/playwright/e2e/audio-player/audio-player.spec.ts +++ b/apps/web/playwright/e2e/audio-player/audio-player.spec.ts @@ -7,6 +7,9 @@ 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 { readFile } from "node:fs/promises"; +import { rejectToast } from "@element-hq/element-web-playwright-common"; + import type { Locator, Page } from "@playwright/test"; import { test, expect, type ExtendedToMatchScreenshotOptions } from "../../element-web-test"; import { SettingLevel } from "../../../src/settings/SettingLevel"; @@ -28,6 +31,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { test.use({ displayName: "Hanako", }); + let roomId: string; const uploadFile = async (app: ElementAppPage, sampleFile: string) => { // Upload a file from the message composer @@ -42,6 +46,37 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { await expect(app.page.getByTestId("audio-player-name").last().filter({ hasText: sampleFile })).toBeVisible(); }; + const sendAudioFile = async (app: ElementAppPage, sampleFile: string, replyToEventId?: string): Promise => { + const file = await readFile(getSampleFilePath(sampleFile)); + const upload = await app.client.uploadContent(file, { name: sampleFile, type: "audio/ogg" }); + const content = { + body: sampleFile, + msgtype: "m.audio", + url: upload.content_uri, + info: { + mimetype: "audio/ogg", + size: file.byteLength, + duration: 1000, + }, + ...(replyToEventId + ? { + "m.relates_to": { + "m.in_reply_to": { + event_id: replyToEventId, + }, + }, + } + : {}), + }; + + const { event_id: eventId } = await app.client.sendEvent(roomId, null, "m.room.message", content); + await expect( + app.page.locator(".mx_EventTile_last").getByRole("region", { name: "Audio player" }), + ).toBeVisible(); + await expect(app.page.getByTestId("audio-player-name").last()).toHaveText(sampleFile); + return eventId; + }; + const scrollToBottomOfTimeline = async (page: Page) => { await page.locator(".mx_RoomView_MessageList").click(); await page.mouse.wheel(0, 100); @@ -139,8 +174,8 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { }; test.beforeEach(async ({ page, app, user }) => { - await app.closeVerifyToast(); - await app.client.createRoom({ name: "Test Room" }); + await rejectToast(page, "Verify this device"); + roomId = await app.client.createRoom({ name: "Test Room" }); await app.viewRoomByName("Test Room"); // Wait until configuration is finished @@ -267,27 +302,9 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { const tile = page.locator(".mx_EventTile_last"); - await uploadFile(app, "upload-first.ogg"); - - // Assert that the audio player is rendered - await expect( - page.locator(".mx_EventTile_last").getByRole("region", { name: "Audio player" }), - ).toBeVisible(); - - await clickButtonReply(tile); - - // Reply to the player with another audio file - await uploadFile(app, "upload-second.ogg"); - - // Assert that the audio player is rendered - await expect( - page.locator(".mx_EventTile_last").getByRole("region", { name: "Audio player" }), - ).toBeVisible(); - - await clickButtonReply(tile); - - // Reply to the player with yet another audio file to create a reply chain - await uploadFile(app, "upload-third.ogg"); + const firstEventId = await sendAudioFile(app, "upload-first.ogg"); + const secondEventId = await sendAudioFile(app, "upload-second.ogg", firstEventId); + await sendAudioFile(app, "upload-third.ogg", secondEventId); // Assert that the audio player is rendered await expect(tile.getByRole("region", { name: "Audio player" })).toBeVisible(); @@ -296,7 +313,9 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { await expect(tile.locator(".mx_ReplyChain")).toHaveCount(2); // Assert that one line contains the user name - await expect(tile.locator(".mx_ReplyChain .mx_ReplyTile_sender").getByText(user.displayName)).toBeVisible(); + await expect( + tile.locator(".mx_ReplyChain .mx_ReplyTile_sender").getByText(user.displayName!), + ).toBeVisible(); // Assert that the other line contains the file button await expect(tile.locator(".mx_ReplyChain .mx_MFileBody")).toBeVisible(); diff --git a/apps/web/playwright/e2e/chat-export/html-export.spec.ts b/apps/web/playwright/e2e/chat-export/html-export.spec.ts index aed032b09b..1436ef59c5 100644 --- a/apps/web/playwright/e2e/chat-export/html-export.spec.ts +++ b/apps/web/playwright/e2e/chat-export/html-export.spec.ts @@ -99,9 +99,12 @@ test.describe("HTML Export", () => { // Send a bunch of messages to populate the room for (let i = 1; i < 10; i++) { - const respone = await app.client.sendMessage(room.roomId, { body: `Testing ${i}`, msgtype: "m.text" }); + const response = await app.client.sendMessage(room!.roomId, { + body: `Testing ${i}`, + msgtype: "m.text", + }); if (i == 1) { - await app.client.reactToMessage(room.roomId, null, respone.event_id, "🙃"); + await app.client.reactToMessage(room!.roomId, null, response.event_id, "🙃"); } } diff --git a/apps/web/playwright/e2e/composer/CIDER.spec.ts b/apps/web/playwright/e2e/composer/CIDER.spec.ts index 89f4cad276..ef92710c10 100644 --- a/apps/web/playwright/e2e/composer/CIDER.spec.ts +++ b/apps/web/playwright/e2e/composer/CIDER.spec.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. */ +import { rejectToastIfExists } from "@element-hq/element-web-playwright-common"; + import { test, expect } from "../../element-web-test"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import { getSampleFilePath } from "../../sample-files"; @@ -18,6 +20,7 @@ test.describe("Composer", () => { botCreateOpts: { displayName: "Bob", }, + lockLeftPanelWidth: false, }); test.use({ @@ -28,7 +31,9 @@ test.describe("Composer", () => { }, }); - test.beforeEach(async ({ room }) => {}); // trigger room fixture + test.beforeEach(async ({ app, room /* trigger room fixture */ }) => { + await rejectToastIfExists(app.page, "Notifications"); + }); test.describe("CIDER", () => { test("sends a message when you click send or press Enter", async ({ page }) => { @@ -69,7 +74,7 @@ test.describe("Composer", () => { test("should allow user to input emoji via graphical picker", async ({ page, app }) => { await app.getComposer(false).getByRole("button", { name: "Emoji" }).click(); - await page.getByTestId("mx_EmojiPicker").locator(".mx_EmojiPicker_item", { hasText: "😇" }).click(); + await page.getByLabel("Emoji picker").getByRole("button", { name: "😇" }).click(); await page.locator(".mx_ContextualMenu_background").click(); // Close emoji picker await page.getByRole("textbox", { name: "Send an unencrypted message…" }).press("Enter"); // Send message @@ -77,13 +82,22 @@ test.describe("Composer", () => { await expect(page.locator(".mx_EventTile_body", { hasText: "😇" })).toBeVisible(); }); + test("renders in narrow viewports", { tag: "@screenshot" }, async ({ page, bot, app }) => { + // Shrink the viewport + await page.setViewportSize({ width: 500, height: 1080 }); + // Shrinking the viewport will collapse the left-panel, so manually expand it. + await app.resizeLeftPanel(150); + // Now take the screenshot + await expect(app.getComposer()).toMatchScreenshot("narrow.png"); + }); + test.describe("render emoji picker with larger viewport height", async () => { test.use({ viewport: { width: 1280, height: 720 } }); test("render emoji picker", { tag: "@screenshot" }, async ({ page, app }) => { await app.getComposer(false).getByRole("button", { name: "Emoji" }).click(); // Mask the background of the screenshot to avoid failing the test just because some // other component have changed its rendering. - await expect(page.getByTestId("mx_EmojiPicker")).toMatchScreenshot("emoji-picker.png", { + await expect(page.getByLabel("Emoji picker")).toMatchScreenshot("emoji-picker.png", { css: ` .mx_ContextualMenu_background { background-color: magenta !important; @@ -99,7 +113,7 @@ test.describe("Composer", () => { await app.getComposer(false).getByRole("button", { name: "Emoji" }).click(); // Mask the background of the screenshot to avoid failing the test just because some // other component have changed its rendering. - await expect(page.getByTestId("mx_EmojiPicker")).toMatchScreenshot("emoji-picker-small.png", { + await expect(page.getByLabel("Emoji picker")).toMatchScreenshot("emoji-picker-small.png", { css: ` .mx_ContextualMenu_background { background-color: magenta !important; @@ -116,7 +130,7 @@ test.describe("Composer", () => { await emojiButton.click(); // Wait for emoji picker to be visible - const emojiPicker = page.getByTestId("mx_EmojiPicker"); + const emojiPicker = page.getByLabel("Emoji picker"); await expect(emojiPicker).toBeVisible(); // Get initial focused element (should be search input) @@ -131,8 +145,8 @@ test.describe("Composer", () => { await page.keyboard.press("Tab"); // Verify we're still within the emoji picker (not back to composer) - const focusedElement = await page.evaluate(() => document.activeElement?.closest(".mx_EmojiPicker")); - expect(focusedElement).not.toBeNull(); + const focusStillInPicker = await emojiPicker.evaluate((el) => el.contains(document.activeElement)); + expect(focusStillInPicker).toBe(true); // Close with Escape key await page.keyboard.press("Escape"); @@ -170,16 +184,17 @@ test.describe("Composer", () => { // Set up a private room so we have another user to mention await app.client.createRoom({ is_direct: true, - invite: [bot.credentials.userId], + invite: [bot.credentials!.userId], }); await app.viewRoomByName("Bob"); const composer = page.getByRole("textbox", { name: "Send an unencrypted message…" }); + await composer.click(); await composer.pressSequentially("@bob"); // Note that we include the user ID here as the room tile is also an 'option' role // with text 'Bob' - await page.getByRole("option", { name: `Bob ${bot.credentials.userId}` }).click(); + await page.getByRole("option", { name: `Bob ${bot.credentials!.userId}` }).click(); await expect(composer.getByText("Bob")).toBeVisible(); await expect(composer).toMatchScreenshot("mention.png"); await composer.press("Enter"); @@ -201,14 +216,8 @@ test.describe("Composer", () => { }); test("can paste a file", async ({ page, bot, app }) => { - // Set up a private room so we have another user to mention - await app.client.createRoom({ - is_direct: true, - invite: [bot.credentials.userId], - }); - await app.viewRoomByName("Bob"); await app.composerDragAndPasteFile("room", getSampleFilePath("riot.png"), "image/png"); - await expect(page.locator(".mx_MImageBody")).toBeVisible(); + await expect(page.locator(".mx_ImageBody")).toBeVisible(); }); }); }); diff --git a/apps/web/playwright/e2e/composer/RTE.spec.ts b/apps/web/playwright/e2e/composer/RTE.spec.ts index 2c5f8071ec..e9ba8aaa32 100644 --- a/apps/web/playwright/e2e/composer/RTE.spec.ts +++ b/apps/web/playwright/e2e/composer/RTE.spec.ts @@ -100,7 +100,7 @@ test.describe("Composer", () => { // Set up a private room so we have another user to mention await app.client.createRoom({ is_direct: true, - invite: [bob.credentials.userId], + invite: [bob.credentials!.userId], }); await app.viewRoomByName("Bob"); @@ -113,11 +113,11 @@ test.describe("Composer", () => { await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty(); // Entering the first letter of the other user's name opens the autocomplete... - await page.getByRole("textbox").pressSequentially(bob.credentials.displayName.slice(0, 1)); + await page.getByRole("textbox").pressSequentially(bob.credentials!.displayName!.slice(0, 1)); // ...with the other user name visible, and clicking that username... - await page.getByTestId("autocomplete-wrapper").getByText(bob.credentials.displayName).click(); + await page.getByTestId("autocomplete-wrapper").getByText(bob.credentials!.displayName!).click(); // ...inserts the username into the composer - const pill = page.getByRole("textbox").getByText(bob.credentials.displayName, { exact: false }); + const pill = page.getByRole("textbox").getByText(bob.credentials!.displayName!, { exact: false }); await expect(pill).toHaveAttribute("contenteditable", "false"); await expect(pill).toHaveAttribute("data-mention-type", "user"); @@ -125,7 +125,7 @@ test.describe("Composer", () => { await page.getByRole("button", { name: "Send message" }).click(); // Typing an @, then other user's name, then trailing space closes the autocomplete - await page.getByRole("textbox").pressSequentially(`@${bob.credentials.displayName} `); + await page.getByRole("textbox").pressSequentially(`@${bob.credentials!.displayName!} `); await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty(); // Send the message to clear the composer @@ -134,7 +134,7 @@ test.describe("Composer", () => { // Moving the cursor back to an "incomplete" mention opens the autocomplete await page .getByRole("textbox") - .pressSequentially(`initial text @${bob.credentials.displayName.slice(0, 1)} abc`); + .pressSequentially(`initial text @${bob.credentials!.displayName!.slice(0, 1)} abc`); await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty(); // Move the cursor left by 4 to put it to: `@B| abc`, check autocomplete displays await page.getByRole("textbox").press("ArrowLeft"); @@ -145,7 +145,7 @@ test.describe("Composer", () => { // Selecting the autocomplete option using Enter inserts it into the composer await page.getByRole("textbox").press("Enter"); - const pill2 = page.getByRole("textbox").getByText(bob.credentials.displayName, { exact: false }); + const pill2 = page.getByRole("textbox").getByText(bob.credentials!.displayName!, { exact: false }); await expect(pill2).toHaveAttribute("contenteditable", "false"); await expect(pill2).toHaveAttribute("data-mention-type", "user"); }); @@ -198,7 +198,7 @@ test.describe("Composer", () => { test("can paste a file", async ({ page, bot, app }) => { await app.composerDragAndPasteFile("room", getSampleFilePath("riot.png"), "image/png"); - await expect(page.locator(".mx_MImageBody")).toBeVisible(); + await expect(page.locator(".mx_ImageBody")).toBeVisible(); }); test("can paste a file in a thread", async ({ page, app }) => { @@ -213,7 +213,22 @@ test.describe("Composer", () => { await tile.getByRole("button", { name: "Reply in thread" }).click(); await app.composerDragAndPasteFile("thread", getSampleFilePath("riot.png"), "image/png"); - await expect(page.locator(".mx_MImageBody")).toBeVisible(); + await expect(page.locator(".mx_ImageBody")).toBeVisible(); + }); + + test.describe(() => { + test.use({ + lockLeftPanelWidth: false, + }); + + test("renders in narrow viewports", { tag: "@screenshot" }, async ({ page, bot, app }) => { + // Shrink the viewport + await page.setViewportSize({ width: 750, height: 1080 }); + // Shrinking the viewport will collapse the left-panel, so manually expand it. + await app.resizeLeftPanel(150); + // Now take the screenshot + await expect(page.locator(".mx_MessageComposer_wrapper")).toMatchScreenshot("narrow.png"); + }); }); test.describe("when Control+Enter is required to send", () => { diff --git a/apps/web/playwright/e2e/crypto/backups-mas.spec.ts b/apps/web/playwright/e2e/crypto/backups-mas.spec.ts index 477ca2a42f..3768c51e4d 100644 --- a/apps/web/playwright/e2e/crypto/backups-mas.spec.ts +++ b/apps/web/playwright/e2e/crypto/backups-mas.spec.ts @@ -89,13 +89,13 @@ test.describe("Key backup reset from elsewhere", () => { await page.getByRole("textbox", { name: "Name" }).fill("test room"); await page.getByRole("button", { name: "Create room" }).click(); - const accessToken = await page.evaluate(() => window.mxMatrixClientPeg.get().getAccessToken()); + const accessToken = await page.evaluate(() => window.mxMatrixClientPeg.get().getAccessToken()!); const csAPI = new TestClientServerAPI(request, homeserver, accessToken); const backupInfo = await csAPI.getCurrentBackupInfo(); - await csAPI.deleteBackupVersion(backupInfo.version); + await csAPI.deleteBackupVersion(backupInfo!.version); await page.getByRole("textbox", { name: "Send a message…" }).fill("/discardsession"); await page.getByRole("button", { name: "Send message" }).click(); diff --git a/apps/web/playwright/e2e/crypto/crypto.spec.ts b/apps/web/playwright/e2e/crypto/crypto.spec.ts index 5d9231b67b..88872052f2 100644 --- a/apps/web/playwright/e2e/crypto/crypto.spec.ts +++ b/apps/web/playwright/e2e/crypto/crypto.spec.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. */ +import { rejectToast } from "@element-hq/element-web-playwright-common"; + import type { Page } from "@playwright/test"; import { expect, test } from "../../element-web-test"; import { autoJoin, createSharedRoomWithUser, enableKeyBackup, verify } from "./utils"; @@ -23,8 +25,8 @@ const checkDMRoom = async (page: Page) => { const startDMWithBob = async (page: Page, bob: Bot) => { await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "New conversation" }).click(); await page.getByRole("menuitem", { name: "Start chat" }).click(); - await page.getByTestId("invite-dialog-input").fill(bob.credentials.userId); - await page.getByRole("option", { name: bob.credentials.displayName }).click(); + await page.getByTestId("invite-dialog-input").fill(bob.credentials!.userId); + await page.getByRole("option", { name: bob.credentials!.displayName! }).click(); await expect(page.getByTestId("invite-dialog-input-wrapper").getByText("Bob")).toBeVisible(); await page.getByRole("button", { name: "Go" }).click(); @@ -80,14 +82,14 @@ test.describe("Cryptography", function () { * @param keyType */ async function verifyKey(app: ElementAppPage, keyType: "master" | "self_signing" | "user_signing") { - const accountData: { encrypted: Record> } = await app.client.evaluate( + const accountData = await app.client.evaluate( (cli, keyType) => cli.getAccountDataFromServer(`m.cross_signing.${keyType}`), keyType, ); - expect(accountData.encrypted).toBeDefined(); - const keys = Object.keys(accountData.encrypted); - const key = accountData.encrypted[keys[0]]; + expect(accountData?.encrypted).toBeDefined(); + const keys = Object.keys(accountData!.encrypted); + const key = accountData!.encrypted[keys[0]]; expect(key.ciphertext).toBeDefined(); expect(key.iv).toBeDefined(); expect(key.mac).toBeDefined(); @@ -117,9 +119,9 @@ test.describe("Cryptography", function () { async function fetchMasterKey() { return await test.step("Fetch master key from server", async () => { const k = await app.client.evaluate(async (cli) => { - const userId = cli.getUserId(); + const userId = cli.getSafeUserId(); const keys = await cli.downloadKeysForUsers([userId]); - return Object.values(keys.master_keys[userId].keys)[0]; + return Object.values(keys.master_keys![userId].keys)[0]; }); console.log(`fetchMasterKey: ${k}`); return k; @@ -136,7 +138,7 @@ test.describe("Cryptography", function () { await encryptionTab.getByRole("button", { name: "Continue" }).click(); // Enter the password - await page.getByPlaceholder("Password").fill(aliceCredentials.password); + await page.getByPlaceholder("Password").fill(aliceCredentials.password!); await page.getByRole("button", { name: "Continue" }).click(); await expect(async () => { @@ -161,7 +163,7 @@ test.describe("Cryptography", function () { await encryptionTab.getByRole("button", { name: "Continue" }).click(); // Enter the password - await page.getByPlaceholder("Password").fill(aliceCredentials.password); + await page.getByPlaceholder("Password").fill(aliceCredentials.password!); await page.getByRole("button", { name: "Continue" }).click(); // Key storage should now be enabled @@ -172,7 +174,7 @@ test.describe("Cryptography", function () { "creating a DM should work, being e2e-encrypted / user verification", { tag: "@screenshot" }, async ({ page, app, bot: bob, user: aliceCredentials }) => { - await app.closeVerifyToast(); + await rejectToast(page, "Verify this device"); await app.client.bootstrapCrossSigning(aliceCredentials); await startDMWithBob(page, bob); // send first message @@ -207,7 +209,7 @@ test.describe("Cryptography", function () { await autoJoin(bob); // we need to have a room with the other user present, so we can open the verification panel - await createSharedRoomWithUser(app, bob.credentials.userId); + await createSharedRoomWithUser(app, bob.credentials!.userId); await verify(app, bob); }); }); diff --git a/apps/web/playwright/e2e/crypto/decryption-failure-messages.spec.ts b/apps/web/playwright/e2e/crypto/decryption-failure-messages.spec.ts index 1dc04ee905..bffa7ff539 100644 --- a/apps/web/playwright/e2e/crypto/decryption-failure-messages.spec.ts +++ b/apps/web/playwright/e2e/crypto/decryption-failure-messages.spec.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. */ +import { rejectToastIfExists } from "@element-hq/element-web-playwright-common"; + import type { Preset, RoomMemberEvent, RoomStateEvent } from "matrix-js-sdk/src/matrix"; import { expect, test } from "../../element-web-test"; import { @@ -46,7 +48,11 @@ test.describe("Cryptography", function () { // Log in again, and see how the message looks. await logIntoElement(page, credentials); // Dismiss the "Back up your chats" toast, otherwise it gets in the way of clicking the room list - await page.getByRole("button", { name: "Dismiss" }).click(); + await page + .getByRole("alert") + .filter({ hasText: "Back up your chats" }) + .getByRole("button", { name: "Dismiss" }) + .click(); await app.viewRoomByName("Test room"); const lastTile = page.locator(".mx_EventTile").last(); await expect(lastTile).toContainText("Historical messages are not available on this device"); @@ -118,6 +124,9 @@ test.describe("Cryptography", function () { user: alice, bot: bob, }) => { + await rejectToastIfExists(page, "Verify this device"); + await rejectToastIfExists(page, "Notifications"); + // Bob creates an encrypted room and sends a message to it. He then invites Alice const roomId = await bob.evaluate( async (client, { alice }) => { @@ -224,6 +233,9 @@ test.describe("Cryptography", function () { user: alice, bot: bob, }) => { + await rejectToastIfExists(page, "Verify this device"); + await rejectToastIfExists(page, "Notifications"); + // Bob: // - creates an encrypted room, // - invites Alice, diff --git a/apps/web/playwright/e2e/crypto/dehydration-mas.spec.ts b/apps/web/playwright/e2e/crypto/dehydration-mas.spec.ts new file mode 100644 index 0000000000..fded708df3 --- /dev/null +++ b/apps/web/playwright/e2e/crypto/dehydration-mas.spec.ts @@ -0,0 +1,79 @@ +/* + Copyright 2026 Element Creations 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 { expect, test } from "../../element-web-test.ts"; +import { masHomeserver } from "../../plugins/homeserver/synapse/masHomeserver.ts"; +import { + autoJoin, + createSharedEncryptedRoomWithUser, + enableKeyBackup, + logOutOfElement, + verifyAfterLogin, +} from "./utils.ts"; +import { registerAccountMas } from "../oidc"; +import { Bot } from "../../pages/bot.ts"; + +test.use({ + ...masHomeserver, + synapseConfig: { + experimental_features: { + msc3814_enabled: true, + }, + }, +}); + +test.describe("Device dehydration, on a MAS-enabled homeserver", () => { + test("Can read messages sent while logged out", async ({ mailpitClient, homeserver, page, app }, testInfo) => { + test.slow(); + const aliceUserId = `alice_${testInfo.testId}`; + const alicePassword = "Pa$sW0rD!"; + + const recoveryKey = + await test.step("Alice registers and sets up recovery => a dehydrated device is created", async () => { + await page.goto("/#/login"); + await page.getByRole("button", { name: "Continue" }).click(); + + await registerAccountMas(page, mailpitClient, aliceUserId, `${aliceUserId}@email.com`, alicePassword); + return await enableKeyBackup(app); + }); + + const [bob, testRoomId] = await test.step("Bob registers and joins a room with Alice", async () => { + const bob = new Bot(page, homeserver, { displayName: "Bob" }); + await autoJoin(bob); + + // Create an encrypted room, and wait for Bob to join it. + const testRoomId = await createSharedEncryptedRoomWithUser(app, bob.credentials!.userId); + + // Even though Alice has seen Bob's join event, Bob may not have done so yet. Wait for the sync to arrive. + await bob.awaitRoomMembership(testRoomId); + return [bob, testRoomId]; + }); + + await test.step("Alice logs out", async () => { + await logOutOfElement(page); + }); + + await test.step("Bob sends a message", async () => { + await bob.sendMessage(testRoomId, "test encrypted 1"); + }); + + await test.step("Alice logs in again", async () => { + await page.getByRole("link", { name: "Sign in" }).click(); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page.getByText("Continue to Element?")).toBeVisible(); + await page.getByRole("button", { name: "Continue" }).click(); + + await verifyAfterLogin(page, recoveryKey); + await app.viewRoomById(testRoomId); + }); + + await test.step("Alice can decrypt Bob's message", async () => { + await expect(page.getByText("test encrypted 1")).toBeVisible(); + }); + }); +}); diff --git a/apps/web/playwright/e2e/crypto/dehydration.spec.ts b/apps/web/playwright/e2e/crypto/dehydration.spec.ts index ccb98e89df..24ee94129b 100644 --- a/apps/web/playwright/e2e/crypto/dehydration.spec.ts +++ b/apps/web/playwright/e2e/crypto/dehydration.spec.ts @@ -8,9 +8,18 @@ Please see LICENSE files in the repository root for full details. import { test, expect } from "../../element-web-test"; import { isDendrite } from "../../plugins/homeserver/dendrite"; -import { createBot, logIntoElement } from "./utils.ts"; +import { + autoJoin, + createBot, + createSharedEncryptedRoomWithUser, + enableKeyBackup, + logIntoElement, + logOutOfElement, + verifyAfterLogin, +} from "./utils.ts"; import { type Client } from "../../pages/client.ts"; import { type ElementAppPage } from "../../pages/ElementAppPage.ts"; +import { Bot } from "../../pages/bot.ts"; const NAME = "Alice"; @@ -18,7 +27,6 @@ test.use({ displayName: NAME, synapseConfig: { experimental_features: { - msc2697_enabled: false, msc3814_enabled: true, }, }, @@ -40,15 +48,10 @@ test.describe("Dehydration", () => { await settings.getByRole("button", { name: "Verify this device" }).click(); await page.getByRole("button", { name: "Can't confirm?" }).click(); await page.getByRole("button", { name: "Continue" }).click(); + await app.closeDialog(); // Set up recovery - await page.getByRole("button", { name: "Get recovery key" }).click(); - await page.getByRole("button", { name: "Continue" }).click(); - const recoveryKey = await page.getByTestId("recoveryKey").innerText(); - await page.getByRole("button", { name: "Continue" }).click(); - await page.getByRole("textbox").fill(recoveryKey); - await page.getByRole("button", { name: "Finish set up" }).click(); - await page.getByRole("button", { name: "Close" }).click(); + await enableKeyBackup(app); await expectDehydratedDeviceEnabled(app); @@ -61,28 +64,7 @@ test.describe("Dehydration", () => { test("'Get recovery key' creates dehydrated device", async ({ app, credentials, page }) => { await logIntoElement(page, credentials); - - const settingsDialogLocator = await app.settings.openUserSettings("Encryption"); - await settingsDialogLocator.getByRole("button", { name: "Get recovery key" }).click(); - - // First it displays an informative panel about the recovery key - await expect(settingsDialogLocator.getByRole("heading", { name: "Get recovery key" })).toBeVisible(); - await settingsDialogLocator.getByRole("button", { name: "Continue" }).click(); - - // Next, it displays the new recovery key. We click on the copy button. - await expect(settingsDialogLocator.getByText("Save your recovery key somewhere safe")).toBeVisible(); - await settingsDialogLocator.getByRole("button", { name: "Copy" }).click(); - const recoveryKey = await app.getClipboard(); - await settingsDialogLocator.getByRole("button", { name: "Continue" }).click(); - - await expect( - settingsDialogLocator.getByText("Enter your recovery key to confirm", { exact: true }), - ).toBeVisible(); - await settingsDialogLocator.getByRole("textbox").fill(recoveryKey); - await settingsDialogLocator.getByRole("button", { name: "Finish set up" }).click(); - - await app.settings.closeDialog(); - + await enableKeyBackup(app); await expectDehydratedDeviceEnabled(app); }); @@ -95,7 +77,7 @@ test.describe("Dehydration", () => { // Set up cross-signing and recovery const { botClient } = await createBot(page, homeserver, credentials); // ... and dehydration - await botClient.evaluate(async (client) => await client.getCrypto().startDehydration()); + await botClient.evaluate(async (client) => await client.getCrypto()!.startDehydration()); const initialDehydratedDeviceIds = await getDehydratedDeviceIds(botClient); expect(initialDehydratedDeviceIds.length).toBe(1); @@ -111,17 +93,11 @@ test.describe("Dehydration", () => { page.getByRole("heading", { name: "Are you sure you want to reset your digital identity?" }), ).toBeVisible(); await page.getByRole("button", { name: "Continue", exact: true }).click(); - await page.getByPlaceholder("Password").fill(credentials.password); + await page.getByPlaceholder("Password").fill(credentials.password!); await page.getByRole("button", { name: "Continue" }).click(); // And set up recovery - const settings = await app.settings.openUserSettings("Encryption"); - await settings.getByRole("button", { name: "Get recovery key" }).click(); - await settings.getByRole("button", { name: "Continue" }).click(); - const recoveryKey = await settings.getByTestId("recoveryKey").innerText(); - await settings.getByRole("button", { name: "Continue" }).click(); - await settings.getByRole("textbox").fill(recoveryKey); - await settings.getByRole("button", { name: "Finish set up" }).click(); + await enableKeyBackup(app); // There should be a brand new dehydrated device await expectDehydratedDeviceEnabled(app); @@ -132,41 +108,72 @@ test.describe("Dehydration", () => { // Create a dehydrated device by setting up recovery (see "'Set up // recovery' creates dehydrated device" test above) - const settingsDialogLocator = await app.settings.openUserSettings("Encryption"); - await settingsDialogLocator.getByRole("button", { name: "Get recovery key" }).click(); - - // First it displays an informative panel about the recovery key - await expect(settingsDialogLocator.getByRole("heading", { name: "Get recovery key" })).toBeVisible(); - await settingsDialogLocator.getByRole("button", { name: "Continue" }).click(); - - // Next, it displays the new recovery key. We click on the copy button. - await expect(settingsDialogLocator.getByText("Save your recovery key somewhere safe")).toBeVisible(); - await settingsDialogLocator.getByRole("button", { name: "Copy" }).click(); - const recoveryKey = await app.getClipboard(); - await settingsDialogLocator.getByRole("button", { name: "Continue" }).click(); - - await expect( - settingsDialogLocator.getByText("Enter your recovery key to confirm", { exact: true }), - ).toBeVisible(); - await settingsDialogLocator.getByRole("textbox").fill(recoveryKey); - await settingsDialogLocator.getByRole("button", { name: "Finish set up" }).click(); - + await enableKeyBackup(app); await expectDehydratedDeviceEnabled(app); // After recovery is set up, we reset our cryptographic identity, which // should drop the dehydrated device. + const settingsDialogLocator = await app.settings.openUserSettings("Encryption"); await settingsDialogLocator.getByRole("button", { name: "Reset cryptographic identity" }).click(); await settingsDialogLocator.getByRole("button", { name: "Continue" }).click(); await expectDehydratedDeviceDisabled(app); }); + + test("Can read messages sent while logged out", async ({ homeserver, credentials, page, app }) => { + const recoveryKey = + await test.step("Alice logs in and sets up recovery => a dehydrated device is created", async () => { + // This test does a page reload to work around a bug, so we need to avoid the `pageWithCredentials` and `user` + // fixtures poke credentials into localStorage via a pageload script. We therefore log in manually. + await logIntoElement(page, credentials); + + // Logging in will have created a cross-signing identity for us. Now set up recovery, to create a dehydrated device. + const recoveryKey = await enableKeyBackup(app); + + await expectDehydratedDeviceEnabled(app); + return recoveryKey; + }); + + const [bob, testRoomId] = await test.step("Bob and Alice make a shared room", async () => { + // As above, we need to avoid the `user` fixture: we therefore also need to avoid the `bot` fixture, which + // depends on the `user` fixture. We just create the bot manually. + const bob = new Bot(page, homeserver, { displayName: "Bob" }); + await autoJoin(bob); + + // create an encrypted room, and wait for Bob to join it. + const testRoomId = await createSharedEncryptedRoomWithUser(app, bob.credentials!.userId); + + // Even though Alice has seen Bob's join event, Bob may not have done so yet. Wait for the sync to arrive. + await bob.awaitRoomMembership(testRoomId); + return [bob, testRoomId]; + }); + + await test.step("Alice logs out", async () => { + await logOutOfElement(page); + }); + + await test.step("Bob sends a message", async () => { + await bob.sendMessage(testRoomId, "test encrypted 1"); + }); + + await test.step("Alice logs back in, and should be able to view Bob's message", async () => { + // Reload to work around a Rust crypto bug where it can hold onto the indexeddb even after logout + // https://github.com/element-hq/element-web/issues/25779 + await page.reload(); + + await logIntoElement(page, credentials); + await verifyAfterLogin(page, recoveryKey); + await app.viewRoomById(testRoomId); + await expect(page.getByText("test encrypted 1")).toBeVisible(); + }); + }); }); async function getDehydratedDeviceIds(client: Client): Promise { return await client.evaluate(async (client) => { - const userId = client.getUserId(); - const devices = await client.getCrypto().getUserDeviceInfo([userId]); - return Array.from(devices.get(userId).values()) + const userId = client.getSafeUserId(); + const devices = await client.getCrypto()!.getUserDeviceInfo([userId]); + return Array.from(devices.get(userId)!.values()) .filter((d) => d.dehydrated) .map((d) => d.deviceId); }); diff --git a/apps/web/playwright/e2e/crypto/device-verification.spec.ts b/apps/web/playwright/e2e/crypto/device-verification.spec.ts index 7fd17177d3..eff50f72c1 100644 --- a/apps/web/playwright/e2e/crypto/device-verification.spec.ts +++ b/apps/web/playwright/e2e/crypto/device-verification.spec.ts @@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import jsQR from "jsqr"; +import { assertNoToasts, getToast, rejectToast } from "@element-hq/element-web-playwright-common"; import type { JSHandle, Locator, Page } from "@playwright/test"; import type { VerificationRequest } from "matrix-js-sdk/src/crypto-api"; @@ -81,11 +82,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { ); // Regression test for https://github.com/element-hq/element-web/issues/29110 - test("No toast after verification, even if the secrets take a while to arrive", async ({ - page, - credentials, - toasts, - }) => { + test("No toast after verification, even if the secrets take a while to arrive", async ({ page, credentials }) => { // Before we log in, the bot creates an encrypted room, so that we can test the toast behaviour that only happens // when we are in an encrypted room. await aliceBotClient.createRoom({ @@ -124,9 +121,8 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { await infoDialog.getByRole("button", { name: "Got it" }).click(); // There should be no toast (other than the notifications one) - await toasts.rejectToast("Verify this device"); - await toasts.rejectToast("Notifications"); - await toasts.assertNoToasts(); + await rejectToast(page, "Notifications"); + await assertNoToasts(page); // There may still be a `/sendToDevice/m.secret.request` in flight, which will later throw an error and cause // a *subsequent* test to fail. Tell playwright to ignore any errors resulting from in-flight routes. @@ -173,8 +169,8 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { await app.client.evaluate(async (cli, aliceBotCredentials) => { const deviceStatus = await cli .getCrypto()! - .getDeviceVerificationStatus(aliceBotCredentials.userId, aliceBotCredentials.deviceId); - if (!deviceStatus.isVerified()) { + .getDeviceVerificationStatus(aliceBotCredentials!.userId, aliceBotCredentials!.deviceId); + if (!deviceStatus!.isVerified()) { throw new Error("Bot device was not verified after QR code verification"); } }, aliceBotClient.credentials); @@ -197,14 +193,14 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { ); test("Verify device with Recovery Key during login", async ({ page, app, credentials, homeserver }) => { - const recoveryKey = (await aliceBotClient.getRecoveryKey()).encodedPrivateKey; + const recoveryKey = (await aliceBotClient.getRecoveryKey()).encodedPrivateKey!; await logIntoElement(page, credentials); await enterRecoveryKeyAndCheckVerified(page, app, recoveryKey); }); test("Verify device with Recovery Key from settings", async ({ page, app, credentials }) => { - const recoveryKey = (await aliceBotClient.getRecoveryKey()).encodedPrivateKey; + const recoveryKey = (await aliceBotClient.getRecoveryKey()).encodedPrivateKey!; await logIntoElement(page, credentials); @@ -273,7 +269,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true); } - test("Handle incoming verification request with SAS", async ({ page, credentials, homeserver, toasts, app }) => { + test("Handle incoming verification request with SAS", async ({ page, credentials, homeserver, app }) => { /* Log in but don't verify the device */ await logIntoElement(page, credentials); const authPage = page.locator(".mx_AuthPage"); @@ -281,7 +277,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { await authPage.getByRole("button", { name: "I'll verify later" }).click(); await page.waitForSelector(".mx_MatrixChat"); - const elementDeviceId = await page.evaluate(() => window.mxMatrixClientPeg.get().getDeviceId()); + const elementDeviceId = await page.evaluate(() => window.mxMatrixClientPeg.get().getDeviceId()!); /* Create an encrypted room so the "Verify this device" toast appears */ await app.client.createRoom({ @@ -303,9 +299,9 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { ); /* Check the toast for the incoming request */ - const toast = await toasts.getToast("Verification requested"); + const toast = await getToast(page, "Verification requested"); // it should contain the device ID of the requesting device - await expect(toast.getByText(`${aliceBotClient.credentials.deviceId} from `)).toBeVisible(); + await expect(toast.getByText(`${aliceBotClient.credentials!.deviceId} from `)).toBeVisible(); // Accept await toast.getByRole("button", { name: "Start verification" }).click(); @@ -315,7 +311,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { /* on the bot side, wait for the verifier to exist ... */ const verifier = await awaitVerifier(botVerificationRequest); // ... confirm ... - void botVerificationRequest.evaluate((verificationRequest) => verificationRequest.verifier.verify()); + void botVerificationRequest.evaluate((verificationRequest) => verificationRequest.verifier!.verify()); // ... and then check the emoji match await doTwoWaySasVerification(page, verifier); @@ -341,7 +337,7 @@ async function readQrCode(base: Locator) { >(async (img) => { // draw the image on a canvas const myCanvas = new OffscreenCanvas(img.width, img.height); - const ctx = myCanvas.getContext("2d"); + const ctx = myCanvas.getContext("2d")!; ctx.drawImage(img, 0, 0); // read the image data @@ -356,5 +352,5 @@ async function readQrCode(base: Locator) { // now we can decode the QR code. const result = jsQR(new Uint8ClampedArray(imageData.buffer), imageData.width, imageData.height); - return new Uint8Array(result.binaryData); + return new Uint8Array(result!.binaryData); } diff --git a/apps/web/playwright/e2e/crypto/event-shields.spec.ts b/apps/web/playwright/e2e/crypto/event-shields.spec.ts index e09fdaf9fc..e25bcf3b73 100644 --- a/apps/web/playwright/e2e/crypto/event-shields.spec.ts +++ b/apps/web/playwright/e2e/crypto/event-shields.spec.ts @@ -12,11 +12,12 @@ import { expect, test } from "../../element-web-test"; import { autoJoin, createSecondBotDevice, - createSharedRoomWithUser, + createSharedEncryptedRoomWithUser, enableKeyBackup, - logIntoElementAndVerify, + logIntoElement, logOutOfElement, verify, + verifyAfterLogin, waitForDevices, } from "./utils"; import { bootstrapCrossSigningForClient } from "../../pages/client.ts"; @@ -39,18 +40,7 @@ test.describe("Cryptography", function () { await autoJoin(bob); // create an encrypted room, and wait for Bob to join it. - testRoomId = await createSharedRoomWithUser(app, bob.credentials.userId, { - name: "TestRoom", - initial_state: [ - { - type: "m.room.encryption", - state_key: "", - content: { - algorithm: "m.megolm.v1.aes-sha2", - }, - }, - ], - }); + testRoomId = await createSharedEncryptedRoomWithUser(app, bob.credentials!.userId); // Even though Alice has seen Bob's join event, Bob may not have done so yet. Wait for the sync to arrive. await bob.awaitRoomMembership(testRoomId); @@ -138,7 +128,7 @@ test.describe("Cryptography", function () { await bobSecondDevice.evaluate((cli) => cli.logout(true)); // wait for the logout to propagate. - await waitForDevices(app, bob.credentials.userId, 1); + await waitForDevices(app, bob.credentials!.userId, 1); // close and reopen the room, to get the shield to update. await app.viewRoomByName("Bob"); @@ -181,7 +171,8 @@ test.describe("Cryptography", function () { window.localStorage.clear(); }); await page.reload(); - await logIntoElementAndVerify(page, aliceCredentials, securityKey); + await logIntoElement(page, aliceCredentials); + await verifyAfterLogin(page, securityKey); /* go back to the test room and find Bob's message again */ await app.viewRoomById(testRoomId); @@ -254,7 +245,7 @@ test.describe("Cryptography", function () { // Workaround for https://github.com/element-hq/element-web/issues/28640: // make sure that Alice has seen Bob's identity before she goes offline. We do this by opening // his user info. - await waitForDevices(app, bob.credentials.userId, 1); + await waitForDevices(app, bob.credentials!.userId, 1); // Our app is blocked from syncing while Bob sends his messages. await app.client.network.goOffline(); @@ -295,7 +286,7 @@ test.describe("Cryptography", function () { // Bob logs in a new device and resets cross-signing const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob); - await bootstrapCrossSigningForClient(await bobSecondDevice.prepareClient(), bob.credentials, true); + await bootstrapCrossSigningForClient(await bobSecondDevice.prepareClient(), bob.credentials!, true); /* should show an error for a message from a previously verified device */ await bobSecondDevice.sendMessage(testRoomId, "test encrypted from user that was previously verified"); diff --git a/apps/web/playwright/e2e/crypto/history-sharing.spec.ts b/apps/web/playwright/e2e/crypto/history-sharing.spec.ts index f2017e9467..186f98eee3 100644 --- a/apps/web/playwright/e2e/crypto/history-sharing.spec.ts +++ b/apps/web/playwright/e2e/crypto/history-sharing.spec.ts @@ -5,7 +5,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 { createNewInstance } from "@element-hq/element-web-playwright-common"; +import { closeReleaseAnnouncement, createNewInstance, rejectToast } from "@element-hq/element-web-playwright-common"; import { expect, test } from "../../element-web-test"; import { ElementAppPage } from "../../pages/ElementAppPage"; @@ -30,6 +30,11 @@ test.describe("History sharing", function () { await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials); await aliceElementApp.closeKeyStorageToast(); + await rejectToast(alicePage, "Notifications"); + + // Close the release announcement about the new room list sections + await closeReleaseAnnouncement(alicePage, "Introducing Sections"); + // Register a second user, and open it in a second instance of the app const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "Bob"); const bobPage = await createNewInstance(browser, bobCredentials, {}, labsFlags); @@ -37,8 +42,6 @@ test.describe("History sharing", function () { await bobElementApp.client.bootstrapCrossSigning(bobCredentials); await bobElementApp.closeKeyStorageToast(); - await aliceElementApp.closeNotificationToast(); - // Create the room and send a message await createRoom(alicePage, "TestRoom", true); @@ -61,12 +64,20 @@ test.describe("History sharing", function () { // Bob should now be able to decrypt the event await expect(bobPage.getByText("A message from Alice")).toBeVisible(); - // Exclude message timestamps and RR avatars from the screenshot. Bob sometimes sees Alice's RR on the + // Mask message timestamps and exclude RR avatars from the screenshot. Bob sometimes sees Alice's RR on the // previous event, which is surprising but not what we're testing here. - const mask = [bobPage.locator(".mx_MessageTimestamp"), bobPage.locator(".mx_ReadReceiptGroup_container")]; - await expect(bobPage.locator(".mx_RoomView_body")).toMatchScreenshot("shared-history-invite-accepted.png", { - mask, - }); + const mask = [bobPage.locator(".mx_MessageTimestamp")]; + await expect(bobPage.locator(".mx_RoomView_timeline")).toMatchScreenshot( + "shared-history-invite-accepted.png", + { + mask, + css: ` + .mx_ReadReceiptGroup_container { + display: none !important; + } + `, + }, + ); }, ); diff --git a/apps/web/playwright/e2e/crypto/invisible-crypto.spec.ts b/apps/web/playwright/e2e/crypto/invisible-crypto.spec.ts index db0961b560..86b6d205ec 100644 --- a/apps/web/playwright/e2e/crypto/invisible-crypto.spec.ts +++ b/apps/web/playwright/e2e/crypto/invisible-crypto.spec.ts @@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details. */ import { expect, test } from "../../element-web-test"; -import { autoJoin, createSecondBotDevice, createSharedRoomWithUser, verify } from "./utils"; +import { autoJoin, createSecondBotDevice, createSharedEncryptedRoomWithUser, verify } from "./utils"; import { bootstrapCrossSigningForClient } from "../../pages/client.ts"; /** Tests for the "invisible crypto" behaviour -- i.e., when the "exclude insecure devices" setting is enabled */ @@ -29,25 +29,14 @@ test.describe("Invisible cryptography", () => { await autoJoin(bob); // create an encrypted room - const testRoomId = await createSharedRoomWithUser(app, bob.credentials.userId, { - name: "TestRoom", - initial_state: [ - { - type: "m.room.encryption", - state_key: "", - content: { - algorithm: "m.megolm.v1.aes-sha2", - }, - }, - ], - }); + const testRoomId = await createSharedEncryptedRoomWithUser(app, bob.credentials!.userId); // Verify Bob await verify(app, bob); // Bob logs in a new device and resets cross-signing const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob); - await bootstrapCrossSigningForClient(await bobSecondDevice.prepareClient(), bob.credentials, true); + await bootstrapCrossSigningForClient(await bobSecondDevice.prepareClient(), bob.credentials!, true); /* should show an error for a message from a previously verified device */ await bobSecondDevice.sendMessage(testRoomId, "test encrypted from user that was previously verified"); diff --git a/apps/web/playwright/e2e/crypto/logout.spec.ts b/apps/web/playwright/e2e/crypto/logout.spec.ts index 6cf02f3408..64bfd9122a 100644 --- a/apps/web/playwright/e2e/crypto/logout.spec.ts +++ b/apps/web/playwright/e2e/crypto/logout.spec.ts @@ -30,7 +30,7 @@ test.describe("Logout tests", () => { const currentDialogLocator = page.locator(".mx_Dialog"); await expect( - currentDialogLocator.getByRole("heading", { name: "You'll lose access to your encrypted messages" }), + currentDialogLocator.getByRole("heading", { name: "You're about to lose access to your encrypted chats" }), ).toBeVisible(); }); @@ -51,7 +51,7 @@ test.describe("Logout tests", () => { await expect(currentDialogLocator.getByText("Are you sure you want to Remove this device?")).toBeVisible(); }); - test("Logout directly if the user has no room keys", async ({ page, app }) => { + test("Ask to set up recovery on logout even if not in encrypted room", async ({ page, app }) => { await createRoom(page, "Clear room", false); await sendMessageInCurrentRoom(page, "Hello public world!"); @@ -60,7 +60,10 @@ test.describe("Logout tests", () => { await locator.getByRole("menuitem", { name: "All settings", exact: true }).click(); await page.getByRole("button", { name: "Remove this device", exact: true }).click(); - // Should have logged out directly - await expect(page.getByRole("heading", { name: "Be in your element" })).toBeVisible(); + const currentDialogLocator = page.locator(".mx_Dialog"); + + await expect( + currentDialogLocator.getByRole("heading", { name: "You're about to lose access to your encrypted chats" }), + ).toBeVisible(); }); }); diff --git a/apps/web/playwright/e2e/crypto/migration.spec.ts b/apps/web/playwright/e2e/crypto/migration.spec.ts index 191568c29c..59c68f7336 100644 --- a/apps/web/playwright/e2e/crypto/migration.spec.ts +++ b/apps/web/playwright/e2e/crypto/migration.spec.ts @@ -6,14 +6,13 @@ 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 path from "path"; +import path from "node:path"; import { readFile } from "node:fs/promises"; -import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { expect, test } from "../../element-web-test"; -const __dirname = dirname(fileURLToPath(import.meta.url)); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); test.describe("migration", { tag: "@no-webkit" }, function () { test.use({ @@ -41,8 +40,8 @@ test.describe("migration", { tag: "@no-webkit" }, function () { // When the progress bar first loads, it should have a high max (one per megolm session to import), and // a relatively low value. const progressBar = page.getByRole("progressbar"); - const initialProgress = parseFloat(await progressBar.getAttribute("value")); - const initialMax = parseFloat(await progressBar.getAttribute("max")); + const initialProgress = parseFloat((await progressBar.getAttribute("value"))!); + const initialMax = parseFloat((await progressBar.getAttribute("max"))!); expect(initialMax).toBeGreaterThan(4000); expect(initialProgress).toBeGreaterThanOrEqual(0); expect(initialProgress).toBeLessThanOrEqual(500); @@ -53,8 +52,8 @@ test.describe("migration", { tag: "@no-webkit" }, function () { async () => { const progressBar = page.getByRole("progressbar"); return ( - (parseFloat(await progressBar.getAttribute("value")) * 100.0) / - parseFloat(await progressBar.getAttribute("max")) + (parseFloat((await progressBar.getAttribute("value"))!) * 100.0) / + parseFloat((await progressBar.getAttribute("max"))!) ); }, { timeout: 60000 }, diff --git a/apps/web/playwright/e2e/crypto/toasts.spec.ts b/apps/web/playwright/e2e/crypto/toasts.spec.ts index 72451a03da..58c029c33b 100644 --- a/apps/web/playwright/e2e/crypto/toasts.spec.ts +++ b/apps/web/playwright/e2e/crypto/toasts.spec.ts @@ -6,9 +6,10 @@ */ import { type GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; +import { assertNoToasts, getToast, rejectToast } from "@element-hq/element-web-playwright-common"; -import { test, expect } from "../../element-web-test"; -import { createBot, deleteCachedSecrets, disableKeyBackup, logIntoElement, logIntoElementAndVerify } from "./utils"; +import { expect, test } from "../../element-web-test"; +import { createBot, deleteCachedSecrets, disableKeyBackup, logIntoElement, verifyAfterLogin } from "./utils"; import { type Bot } from "../../pages/bot"; // Mask the background of the screenshot to avoid failing the test just because some @@ -28,7 +29,8 @@ test.describe("Key storage out of sync toast", () => { const res = await createBot(page, homeserver, credentials); recoveryKey = res.recoveryKey; - await logIntoElementAndVerify(page, credentials, recoveryKey.encodedPrivateKey); + await logIntoElement(page, credentials); + await verifyAfterLogin(page, recoveryKey.encodedPrivateKey!); await deleteCachedSecrets(page); }); @@ -41,7 +43,7 @@ test.describe("Key storage out of sync toast", () => { await page.getByRole("button", { name: "Enter recovery key" }).click(); - await page.getByRole("textbox", { name: "Recovery Key" }).fill(recoveryKey.encodedPrivateKey); + await page.getByRole("textbox", { name: "Recovery Key" }).fill(recoveryKey.encodedPrivateKey!); await page.getByRole("button", { name: "Continue" }).click(); await expect(page.getByRole("button", { name: "Enter recovery key" })).not.toBeVisible(); @@ -61,16 +63,17 @@ test.describe("Key storage out of sync toast", () => { }); test.describe("'Turn on key storage' toast", () => { - let botClient: Bot | undefined; + let botClient: Bot; - test.beforeEach(async ({ page, homeserver, credentials, toasts }) => { + test.beforeEach(async ({ page, homeserver, credentials }) => { // Set up all crypto stuff. Key storage defaults to on. const res = await createBot(page, homeserver, credentials); const recoveryKey = res.recoveryKey; botClient = res.botClient; - await logIntoElementAndVerify(page, credentials, recoveryKey.encodedPrivateKey); + await logIntoElement(page, credentials); + await verifyAfterLogin(page, recoveryKey.encodedPrivateKey!); // We won't be prompted for crypto setup unless we have an e2e room, so make one await page @@ -81,13 +84,13 @@ test.describe("'Turn on key storage' toast", () => { await page.getByRole("textbox", { name: "Name" }).fill("Test room"); await page.getByRole("button", { name: "Create room" }).click(); - await toasts.rejectToast("Notifications"); + await rejectToast(page, "Notifications"); }); - test("should not show toast if key storage is on", async ({ page, toasts }) => { + test("should not show toast if key storage is on", async ({ page }) => { // Given the default situation after signing in // Then no toast is shown (because key storage is on) - await toasts.assertNoToasts(); + await assertNoToasts(page); // When we reload await page.reload(); @@ -96,15 +99,15 @@ test.describe("'Turn on key storage' toast", () => { await new Promise((resolve) => setTimeout(resolve, 2000)); // Then still no toast is shown - await toasts.assertNoToasts(); + await assertNoToasts(page); }); - test("should not show toast if key storage is off because we turned it off", async ({ app, page, toasts }) => { + test("should not show toast if key storage is off because we turned it off", async ({ app, page }) => { // Given the backup is disabled because we disabled it await disableKeyBackup(app); // Then no toast is shown - await toasts.assertNoToasts(); + await assertNoToasts(page); // When we reload await page.reload(); @@ -113,13 +116,14 @@ test.describe("'Turn on key storage' toast", () => { await new Promise((resolve) => setTimeout(resolve, 2000)); // Then still no toast is shown - await toasts.assertNoToasts(); + await assertNoToasts(page); }); - test("should show toast if key storage is off but account data is missing", async ({ app, page, toasts }) => { + test("should show toast if key storage is off but account data is missing", async ({ app, page }) => { // Given the backup is disabled but we didn't set account data saying that is expected await disableKeyBackup(app); - await botClient.setAccountData("m.org.matrix.custom.backup_disabled", { disabled: false }); + await botClient.setAccountData("m.org.matrix.custom.backup_disabled", {} as any as { disabled: boolean }); + await botClient.setAccountData("m.key_backup", {} as any as { enabled: boolean }); // Wait for the account data setting to stick await new Promise((resolve) => setTimeout(resolve, 2000)); @@ -128,7 +132,7 @@ test.describe("'Turn on key storage' toast", () => { await page.reload(); // Then the toast is displayed - let toast = await toasts.getToast("Turn on key storage"); + let toast = await getToast(page, "Turn on key storage"); // And when we click "Continue" await toast.getByRole("button", { name: "Continue" }).click(); @@ -140,7 +144,7 @@ test.describe("'Turn on key storage' toast", () => { await page.getByRole("button", { name: "Close dialog" }).click(); // Then we see the toast again - toast = await toasts.getToast("Turn on key storage"); + toast = await getToast(page, "Turn on key storage"); // And when we click "Dismiss" await toast.getByRole("button", { name: "Dismiss" }).click(); @@ -154,7 +158,7 @@ test.describe("'Turn on key storage' toast", () => { await page.getByTestId("dialog-background").click({ force: true, position: { x: 10, y: 10 } }); // Then we see the toast again - toast = await toasts.getToast("Turn on key storage"); + toast = await getToast(page, "Turn on key storage"); // And when we click Dismiss and then "Go to Settings" await toast.getByRole("button", { name: "Dismiss" }).click(); @@ -165,12 +169,12 @@ test.describe("'Turn on key storage' toast", () => { // And when we close that, see the toast, click Dismiss, and Yes, Dismiss await page.getByRole("button", { name: "Close dialog" }).click(); - toast = await toasts.getToast("Turn on key storage"); + toast = await getToast(page, "Turn on key storage"); await toast.getByRole("button", { name: "Dismiss" }).click(); await page.getByRole("button", { name: "Yes, dismiss" }).click(); // Then the toast is gone - await toasts.assertNoToasts(); + await assertNoToasts(page); }); }); diff --git a/apps/web/playwright/e2e/crypto/user-verification.spec.ts b/apps/web/playwright/e2e/crypto/user-verification.spec.ts index ebe86c0a6e..f4cc78dfd5 100644 --- a/apps/web/playwright/e2e/crypto/user-verification.spec.ts +++ b/apps/web/playwright/e2e/crypto/user-verification.spec.ts @@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import { type Preset, type Visibility } from "matrix-js-sdk/src/matrix"; +import { getToast } from "@element-hq/element-web-playwright-common"; import { test, expect } from "../../element-web-test"; import { doTwoWaySasVerification, awaitVerifier, waitForDevices } from "./utils"; @@ -36,10 +37,9 @@ test.describe("User verification", () => { page, bot: bob, user: aliceCredentials, - toasts, room: { roomId: dmRoomId }, }) => { - await waitForDevices(app, bob.credentials.userId, 1); + await waitForDevices(app, bob.credentials!.userId, 1); await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible(); const avatar = page.getByRole("button", { name: "Avatar" }); await avatar.click(); @@ -47,22 +47,22 @@ test.describe("User verification", () => { // once Alice has joined, Bob starts the verification const bobVerificationRequest = await bob.evaluateHandle( async (client, { dmRoomId, aliceCredentials }) => { - const room = client.getRoom(dmRoomId); + const room = client.getRoom(dmRoomId)!; while (room.getMember(aliceCredentials.userId)?.membership !== "join") { await new Promise((resolve) => { room.once(window.matrixcs.RoomStateEvent.Members, resolve); }); } - return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId); + return client.getCrypto()!.requestVerificationDM(aliceCredentials.userId, dmRoomId); }, { dmRoomId, aliceCredentials }, ); // there should also be a toast - const toast = await toasts.getToast("Verification requested"); + const toast = await getToast(page, "Verification requested"); // it should contain the details of the requesting user - await expect(toast.getByText(`Bob (${bob.credentials.userId})`)).toBeVisible(); + await expect(toast.getByText(`Bob (${bob.credentials!.userId})`)).toBeVisible(); // Accept await toast.getByRole("button", { name: "Verify User" }).click(); @@ -91,10 +91,9 @@ test.describe("User verification", () => { page, bot: bob, user: aliceCredentials, - toasts, room: { roomId: dmRoomId }, }) => { - await waitForDevices(app, bob.credentials.userId, 1); + await waitForDevices(app, bob.credentials!.userId, 1); await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible(); const avatar = page.getByRole("button", { name: "Avatar" }); await avatar.click(); @@ -102,20 +101,20 @@ test.describe("User verification", () => { // once Alice has joined, Bob starts the verification const bobVerificationRequest = await bob.evaluateHandle( async (client, { dmRoomId, aliceCredentials }) => { - const room = client.getRoom(dmRoomId); + const room = client.getRoom(dmRoomId)!; while (room.getMember(aliceCredentials.userId)?.membership !== "join") { await new Promise((resolve) => { room.once(window.matrixcs.RoomStateEvent.Members, resolve); }); } - return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId); + return client.getCrypto()!.requestVerificationDM(aliceCredentials.userId, dmRoomId); }, { dmRoomId, aliceCredentials }, ); // Accept verification via toast - const toast = await toasts.getToast("Verification requested"); + const toast = await getToast(page, "Verification requested"); await toast.getByRole("button", { name: "Verify User" }).click(); // Wait for the QR code to be rendered. If we don't do this, then the QR code can be rendered just as diff --git a/apps/web/playwright/e2e/crypto/utils.ts b/apps/web/playwright/e2e/crypto/utils.ts index f08863ccdb..194a10bf22 100644 --- a/apps/web/playwright/e2e/crypto/utils.ts +++ b/apps/web/playwright/e2e/crypto/utils.ts @@ -51,7 +51,7 @@ export async function createBot( botClient.setCredentials(credentials); // Backup is prepared in the background. Poll until it is ready. const botClientHandle = await botClient.prepareClient(); - let expectedBackupVersion: string; + let expectedBackupVersion: string | null; await expect .poll(async () => { expectedBackupVersion = await botClientHandle.evaluate((cli) => @@ -63,7 +63,7 @@ export async function createBot( const recoveryKey = await botClient.getRecoveryKey(); - return { botClient, recoveryKey, expectedBackupVersion }; + return { botClient, recoveryKey, expectedBackupVersion: expectedBackupVersion! }; } /** @@ -98,13 +98,15 @@ export async function waitForVerificationRequest(client: Client): Promise): Promise { return verifier.evaluate((verifier) => { const event = verifier.getShowSasCallbacks(); - if (event) return event.sas.emoji; + if (event) { + return event.sas.emoji!; + } return new Promise((resolve) => { const onShowSas = (event: ShowSasCallbacks) => { verifier.off("show_sas" as VerifierEvent, onShowSas); void event.confirm(); - resolve(event.sas.emoji); + resolve(event.sas.emoji!); }; verifier.on("show_sas" as VerifierEvent, onShowSas); @@ -117,24 +119,24 @@ export function handleSasVerification(verifier: JSHandle): Promise { const { userId, deviceId, keys } = await app.client.evaluate(async (cli: MatrixClient) => { - const deviceId = cli.getDeviceId(); - const userId = cli.getUserId(); + const deviceId = cli.getDeviceId()!; + const userId = cli.getSafeUserId(); const keys = await cli.downloadKeysForUsers([userId]); return { userId, deviceId, keys }; }); // there should be three cross-signing keys - expect(keys.master_keys[userId]).toHaveProperty("keys"); - expect(keys.self_signing_keys[userId]).toHaveProperty("keys"); - expect(keys.user_signing_keys[userId]).toHaveProperty("keys"); + expect(keys.master_keys![userId]).toHaveProperty("keys"); + expect(keys.self_signing_keys![userId]).toHaveProperty("keys"); + expect(keys.user_signing_keys![userId]).toHaveProperty("keys"); // and the device should be signed by the self-signing key - const selfSigningKeyId = Object.keys(keys.self_signing_keys[userId].keys)[0]; + const selfSigningKeyId = Object.keys(keys.self_signing_keys![userId].keys)[0]; expect(keys.device_keys[userId][deviceId]).toBeDefined(); - const myDeviceSignatures = keys.device_keys[userId][deviceId].signatures[userId]; + const myDeviceSignatures = keys.device_keys[userId][deviceId].signatures![userId]; expect(myDeviceSignatures[selfSigningKeyId]).toBeDefined(); } @@ -190,7 +192,7 @@ export async function checkDeviceIsConnectedKeyBackup( // We have a key backup expect(backupInfo).toBeDefined(); // The key backup version is as expected - expect(backupInfo.version).toBe(expectedBackupVersion); + expect(backupInfo!.version).toBe(expectedBackupVersion); // The active backup version is as expected expect(activeBackupVersion).toBe(expectedBackupVersion); // The backup key is stored in 4S @@ -211,19 +213,19 @@ export async function logIntoElement(page: Page, credentials: Credentials) { await page.goto("/#/login"); await page.getByRole("textbox", { name: "Username" }).fill(credentials.userId); - await page.getByPlaceholder("Password").fill(credentials.password); + await page.getByPlaceholder("Password").fill(credentials.password!); await page.getByRole("button", { name: "Sign in" }).click(); } /** - * Fill in the login form in Element with the given creds, and then complete the `CompleteSecurity` step, using the - * given recovery key. (Normally this will verify the new device using the secrets from 4S.) + * Complete the `CompleteSecurity` step that happens after login, using the given recovery key. + * (Normally this will verify the new device using the secrets from 4S.) * * Afterwards, waits for the application to redirect to the home page. + * + * This is normally useful after a call to {@link logIntoElement} or {@link logInAccountMas}. */ -export async function logIntoElementAndVerify(page: Page, credentials: Credentials, recoveryKey: string) { - await logIntoElement(page, credentials); - +export async function verifyAfterLogin(page: Page, recoveryKey: string) { await page.locator(".mx_AuthPage").getByRole("button", { name: "Use recovery key" }).click(); const useSecurityKey = page.locator(".mx_Dialog").getByRole("button", { name: "Use recovery key" }); @@ -248,7 +250,7 @@ export async function logIntoElementAndVerify(page: Page, credentials: Credentia * Click the "sign out" option in Element, and wait for the welcome page to load * * @param page - Playwright `Page` object. - * @param discardKeys - if true, expect a "You'll lose access to your encrypted messages" dialog, and dismiss it. + * @param discardKeys - if true, expect a "You're about to lose access to your encrypted chats" dialog, and dismiss it. */ export async function logOutOfElement(page: Page, discardKeys: boolean = false) { await page.getByRole("button", { name: "User menu" }).click(); @@ -256,7 +258,7 @@ export async function logOutOfElement(page: Page, discardKeys: boolean = false) await page.getByRole("menu", { name: "User menu" }).getByRole("menuitem", { name: "All settings" }).click(); await page.getByRole("button", { name: "Remove this device" }).click(); if (discardKeys) { - await page.getByRole("button", { name: "I don't want my encrypted messages" }).click(); + await page.getByRole("button", { name: "Remove this device anyway" }).click(); } else { await page.locator(".mx_Dialog .mx_QuestionDialog").getByRole("button", { name: "Remove this device" }).click(); } @@ -379,7 +381,7 @@ export async function completeCreateSecretStorageDialog( // the step is quite quick, and playwright can miss it, so we can't test for it. if (opts && Object.hasOwn(opts, "accountPassword")) { await expect(currentDialogLocator.getByRole("heading", { name: "Setting up keys" })).toBeVisible(); - await page.getByPlaceholder("Password").fill(opts!.accountPassword); + await page.getByPlaceholder("Password").fill(opts!.accountPassword!); await currentDialogLocator.getByRole("button", { name: "Continue" }).click(); } @@ -406,6 +408,7 @@ export async function copyAndContinue(page: Page) { * @param opts - other options for the createRoom call * * @returns a promise which resolves to the room ID + * @see createSharedEncryptedRoomWithUser */ export async function createSharedRoomWithUser( app: ElementAppPage, @@ -423,6 +426,32 @@ export async function createSharedRoomWithUser( return roomId; } +/** + * Create a shared, encrypted room with the given user, and wait for them to join + * + * @param other - UserID of the other user + * @param opts - other options for the createRoom call + * + * @returns a promise which resolves to the room ID + * @see createSharedRoomWithUser + */ +export async function createSharedEncryptedRoomWithUser( + app: ElementAppPage, + other: string, + opts: Omit = { name: "TestRoom" }, +): Promise { + opts = structuredClone(opts); + opts.initial_state ??= []; + opts.initial_state.push({ + type: "m.room.encryption", + state_key: "", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + }); + return createSharedRoomWithUser(app, other, opts); +} + /** * Send a message in the current room * @param page @@ -568,7 +597,7 @@ export async function createSecondBotDevice(page: Page, homeserver: HomeserverIn bootstrapSecretStorage: false, bootstrapCrossSigning: false, }); - bobSecondDevice.setCredentials(await homeserver.loginUser(bob.credentials.userId, bob.credentials.password)); + bobSecondDevice.setCredentials(await homeserver.loginUser(bob.credentials!.userId, bob.credentials!.password!)); await bobSecondDevice.prepareClient(); return bobSecondDevice; } @@ -611,7 +640,7 @@ export async function waitForDevices( for (let i = 0; i < 10; ++i) { const userDeviceMap = await cli.getCrypto()?.getUserDeviceInfo([userId], true); const deviceMap = userDeviceMap?.get(userId); - if (deviceMap.size === expectedNumberOfDevices) return true; + if (deviceMap?.size === expectedNumberOfDevices) return true; await new Promise((r) => setTimeout(r, 500)); } return false; diff --git a/apps/web/playwright/e2e/devtools/lowbandwidth.spec.ts b/apps/web/playwright/e2e/devtools/lowbandwidth.spec.ts index 8d5022623d..025868a815 100644 --- a/apps/web/playwright/e2e/devtools/lowbandwidth.spec.ts +++ b/apps/web/playwright/e2e/devtools/lowbandwidth.spec.ts @@ -5,6 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +import { rejectToast } from "@element-hq/element-web-playwright-common"; + import { test, expect } from "../../element-web-test"; import { getSampleFilePath } from "../../sample-files"; @@ -19,7 +21,7 @@ test.describe("Devtools", () => { const profileSettings = userSettings.locator(".mx_UserProfileSettings"); await profileSettings.getByAltText("Upload").setInputFiles(getSampleFilePath("riot.png")); await app.closeDialog(); - await app.closeVerifyToast(); + await rejectToast(page, "Verify this device"); // Create an initial room. const createRoomDialog = await app.openCreateRoomDialog(); diff --git a/apps/web/playwright/e2e/editing/editing.spec.ts b/apps/web/playwright/e2e/editing/editing.spec.ts index 9b0efecb26..8b8180c77b 100644 --- a/apps/web/playwright/e2e/editing/editing.spec.ts +++ b/apps/web/playwright/e2e/editing/editing.spec.ts @@ -362,7 +362,7 @@ test.describe("Editing", () => { const messageTile = page.locator(`[data-event-id="${originalEventId}"]`); // at this point, the edit event should still be unknown const timeline = await app.client.evaluate( - (cli, { testRoomId, editEventId }) => cli.getRoom(testRoomId).getTimelineForEvent(editEventId), + (cli, { testRoomId, editEventId }) => cli.getRoom(testRoomId)!.getTimelineForEvent(editEventId), { testRoomId, editEventId }, ); expect(timeline).toBeNull(); diff --git a/apps/web/playwright/e2e/feedback/rageshakes.spec.ts b/apps/web/playwright/e2e/feedback/rageshakes.spec.ts index 58476f04ac..9fae568c82 100644 --- a/apps/web/playwright/e2e/feedback/rageshakes.spec.ts +++ b/apps/web/playwright/e2e/feedback/rageshakes.spec.ts @@ -94,7 +94,7 @@ test.describe("Rageshakes", () => { if (request.method() !== "POST") { throw Error("Expected POST"); } - const fields = formDataParser(request.postData(), await request.headerValue("Content-Type")); + const fields = formDataParser(request.postData()!, await request.headerValue("Content-Type")); expect(fields.text).toEqual( "These are some notes\n\nIssue: https://github.com/element-hq/element-web/12345", ); diff --git a/apps/web/playwright/e2e/file-upload/image-upload.spec.ts b/apps/web/playwright/e2e/file-upload/image-upload.spec.ts index 67ca01bd09..2af553ed66 100644 --- a/apps/web/playwright/e2e/file-upload/image-upload.spec.ts +++ b/apps/web/playwright/e2e/file-upload/image-upload.spec.ts @@ -37,7 +37,7 @@ test.describe("Image Upload", () => { test("should allow upload via drag and drop", { tag: "@screenshot" }, async ({ page, app }) => { await app.composerDragAndUploadFiles("room", getSampleFilePath("riot.png"), "image/png"); await app.timeline.scrollToBottom(); - const imgTile = page.locator(".mx_MImageBody").first(); + const imgTile = page.locator(".mx_ImageBody").first(); await expect(imgTile).toBeVisible(); }); }); diff --git a/apps/web/playwright/e2e/forgot-password/forgot-password.spec.ts b/apps/web/playwright/e2e/forgot-password/forgot-password.spec.ts index d075afda73..1b9dad290c 100644 --- a/apps/web/playwright/e2e/forgot-password/forgot-password.spec.ts +++ b/apps/web/playwright/e2e/forgot-password/forgot-password.spec.ts @@ -14,7 +14,6 @@ import { isDendrite } from "../../plugins/homeserver/dendrite"; const email = "user@nowhere.dummy"; const test = base.extend({ - // eslint-disable-next-line no-empty-pattern credentials: async ({}, use, testInfo) => { await use({ username: `user_${testInfo.testId}`, @@ -57,7 +56,7 @@ test.describe("Forgot Password", () => { "renders email verification dialog properly", { tag: "@screenshot" }, async ({ page, homeserver, credentials }) => { - const user = await homeserver.registerUser(credentials.username, credentials.password); + const user = await homeserver.registerUser(credentials.username, credentials.password!); await homeserver.setThreepid(user.userId, "email", email); @@ -74,8 +73,8 @@ test.describe("Forgot Password", () => { await page.getByRole("button", { name: "Next" }).click(); - await page.getByRole("textbox", { name: "New Password", exact: true }).fill(credentials.password); - await page.getByRole("textbox", { name: "Confirm new password", exact: true }).fill(credentials.password); + await page.getByRole("textbox", { name: "New Password", exact: true }).fill(credentials.password!); + await page.getByRole("textbox", { name: "Confirm new password", exact: true }).fill(credentials.password!); await page.getByRole("button", { name: "Reset password" }).click(); diff --git a/apps/web/playwright/e2e/integration-manager/kick.spec.ts b/apps/web/playwright/e2e/integration-manager/kick.spec.ts index b1aa9eff52..e41d95d2bb 100644 --- a/apps/web/playwright/e2e/integration-manager/kick.spec.ts +++ b/apps/web/playwright/e2e/integration-manager/kick.spec.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. */ +import { closeReleaseAnnouncement } from "@element-hq/element-web-playwright-common"; + import type { Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; import { openIntegrationManager } from "./utils"; @@ -139,20 +141,30 @@ test.describe("Integration Manager: Kick", () => { await app.viewRoomByName(ROOM_NAME); }); + test.beforeEach(async ({ page, user, app, room }) => { + // Close the release announcement about the new room list sections + await closeReleaseAnnouncement(page, "Introducing Sections"); + }); + test("should kick the target", async ({ page, app, bot: targetUser, room }) => { await app.viewRoomByName(ROOM_NAME); - await app.client.inviteUser(room.roomId, targetUser.credentials.userId); + await app.client.inviteUser(room.roomId, targetUser.credentials!.userId); await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible(); await openIntegrationManager(app); - await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId); + await sendActionFromIntegrationManager( + page, + integrationManagerUrl, + room.roomId, + targetUser.credentials!.userId, + ); await closeIntegrationManager(page, integrationManagerUrl); await expectKickedMessage(page, true); }); test("should not kick the target if lacking permissions", async ({ page, app, user, bot: targetUser, room }) => { await app.viewRoomByName(ROOM_NAME); - await app.client.inviteUser(room.roomId, targetUser.credentials.userId); + await app.client.inviteUser(room.roomId, targetUser.credentials!.userId); await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible(); await app.client.sendStateEvent(room.roomId, "m.room.power_levels", { @@ -163,31 +175,46 @@ test.describe("Integration Manager: Kick", () => { }); await openIntegrationManager(app); - await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId); + await sendActionFromIntegrationManager( + page, + integrationManagerUrl, + room.roomId, + targetUser.credentials!.userId, + ); await closeIntegrationManager(page, integrationManagerUrl); await expectKickedMessage(page, false); }); test("should no-op if the target already left", async ({ page, app, bot: targetUser, room }) => { await app.viewRoomByName(ROOM_NAME); - await app.client.inviteUser(room.roomId, targetUser.credentials.userId); + await app.client.inviteUser(room.roomId, targetUser.credentials!.userId); await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible(); await targetUser.leave(room.roomId); await openIntegrationManager(app); - await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId); + await sendActionFromIntegrationManager( + page, + integrationManagerUrl, + room.roomId, + targetUser.credentials!.userId, + ); await closeIntegrationManager(page, integrationManagerUrl); await expectKickedMessage(page, false); }); test("should no-op if the target was banned", async ({ page, app, bot: targetUser, room }) => { await app.viewRoomByName(ROOM_NAME); - await app.client.inviteUser(room.roomId, targetUser.credentials.userId); + await app.client.inviteUser(room.roomId, targetUser.credentials!.userId); await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible(); - await app.client.ban(room.roomId, targetUser.credentials.userId); + await app.client.ban(room.roomId, targetUser.credentials!.userId); await openIntegrationManager(app); - await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId); + await sendActionFromIntegrationManager( + page, + integrationManagerUrl, + room.roomId, + targetUser.credentials!.userId, + ); await closeIntegrationManager(page, integrationManagerUrl); await expectKickedMessage(page, false); }); @@ -196,7 +223,12 @@ test.describe("Integration Manager: Kick", () => { await app.viewRoomByName(ROOM_NAME); await openIntegrationManager(app); - await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId); + await sendActionFromIntegrationManager( + page, + integrationManagerUrl, + room.roomId, + targetUser.credentials!.userId, + ); await closeIntegrationManager(page, integrationManagerUrl); await expectKickedMessage(page, false); }); diff --git a/apps/web/playwright/e2e/integration-manager/send_event.spec.ts b/apps/web/playwright/e2e/integration-manager/send_event.spec.ts index 7edcf9812b..f100e7ea64 100644 --- a/apps/web/playwright/e2e/integration-manager/send_event.spec.ts +++ b/apps/web/playwright/e2e/integration-manager/send_event.spec.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. */ +import { closeReleaseAnnouncement, rejectToast } from "@element-hq/element-web-playwright-common"; + import type { Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; import { openIntegrationManager } from "./utils"; @@ -103,6 +105,11 @@ test.describe("Integration Manager: Send Event", () => { }); test.beforeEach(async ({ page, user, app, room }) => { + await rejectToast(page, "Verify this device"); + await rejectToast(page, "Notifications"); + // Close the release announcement about the new room list sections + await closeReleaseAnnouncement(page, "Introducing Sections"); + await app.client.setAccountData("m.widgets", { "m.integration_manager": { content: { diff --git a/apps/web/playwright/e2e/invite/invite-dialog.spec.ts b/apps/web/playwright/e2e/invite/invite-dialog.spec.ts index 811f948f05..ea518d588d 100644 --- a/apps/web/playwright/e2e/invite/invite-dialog.spec.ts +++ b/apps/web/playwright/e2e/invite/invite-dialog.spec.ts @@ -7,6 +7,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 { rejectToast } from "@element-hq/element-web-playwright-common"; + import { test, expect } from "../../element-web-test"; /** @@ -53,13 +55,13 @@ test.describe("Invite dialog", function () { await expect(other.locator(".mx_InviteDialog_identityServer")).not.toBeVisible(); - await other.getByTestId("invite-dialog-input").fill(bot.credentials.userId); + await other.getByTestId("invite-dialog-input").fill(bot.credentials!.userId); // Assert that notification about identity servers appears after typing userId await expect(other.locator(".mx_InviteDialog_identityServer")).toBeVisible(); // Assert that the bot id is rendered properly - await expect(other.getByRole("option", { name: botName }).getByText(bot.credentials.userId)).toBeVisible(); + await expect(other.getByRole("option", { name: botName }).getByText(bot.credentials!.userId)).toBeVisible(); await other.getByRole("option", { name: botName }).click(); @@ -91,7 +93,7 @@ test.describe("Invite dialog", function () { "should support inviting a user to Direct Messages", { tag: "@screenshot" }, async ({ page, app, user, bot }) => { - await app.closeVerifyToast(); + await rejectToast(page, "Verify this device"); await page .getByRole("navigation", { name: "Room list" }) .getByRole("button", { name: "New conversation" }) @@ -110,9 +112,9 @@ test.describe("Invite dialog", function () { // Take a snapshot of the invite dialog await expect(page.locator(".mx_Dialog")).toMatchScreenshot("invite-dialog-dm-without-user.png"); - await other.getByTestId("invite-dialog-input").fill(bot.credentials.userId); + await other.getByTestId("invite-dialog-input").fill(bot.credentials!.userId); - await expect(other.getByRole("option", { name: botName }).getByText(bot.credentials.userId)).toBeVisible(); + await expect(other.getByRole("option", { name: botName }).getByText(bot.credentials!.userId)).toBeVisible(); await other.getByRole("option", { name: botName }).click(); await expect(other.getByTestId("invite-dialog-input-wrapper").getByText(botName)).toBeVisible(); diff --git a/apps/web/playwright/e2e/knock/create-knock-room.spec.ts b/apps/web/playwright/e2e/knock/create-knock-room.spec.ts index 1813907160..5097d12096 100644 --- a/apps/web/playwright/e2e/knock/create-knock-room.spec.ts +++ b/apps/web/playwright/e2e/knock/create-knock-room.spec.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. */ +import { rejectToast } from "@element-hq/element-web-playwright-common"; + import { test, expect } from "../../element-web-test"; import { waitForRoom } from "../utils"; import { Filter } from "../../pages/Spotlight"; @@ -19,7 +21,7 @@ test.describe("Create Knock Room", () => { }); test("should create a knock room", async ({ page, app, user }) => { - await app.closeVerifyToast(); + await rejectToast(page, "Verify this device"); const dialog = await app.openCreateRoomDialog(); await dialog.getByRole("textbox", { name: "Name" }).fill("Cybersecurity"); @@ -39,7 +41,7 @@ test.describe("Create Knock Room", () => { }); test("should create a room and change a join rule to knock", async ({ page, app, user }) => { - await app.closeVerifyToast(); + await rejectToast(page, "Verify this device"); const dialog = await app.openCreateRoomDialog(); await dialog.getByRole("textbox", { name: "Name" }).fill("Cybersecurity"); @@ -63,7 +65,7 @@ test.describe("Create Knock Room", () => { }); test("should create a public knock room", async ({ page, app, user }) => { - await app.closeVerifyToast(); + await rejectToast(page, "Verify this device"); const dialog = await app.openCreateRoomDialog(); await dialog.getByRole("textbox", { name: "Name" }).fill("Cybersecurity"); diff --git a/apps/web/playwright/e2e/lazy-loading/lazy-loading.spec.ts b/apps/web/playwright/e2e/lazy-loading/lazy-loading.spec.ts index f6f098a079..7d286b27d4 100644 --- a/apps/web/playwright/e2e/lazy-loading/lazy-loading.spec.ts +++ b/apps/web/playwright/e2e/lazy-loading/lazy-loading.spec.ts @@ -80,8 +80,12 @@ test.describe("Lazy Loading", () => { async function checkPaginatedDisplayNames(app: ElementAppPage, charlies: Bot[]) { await app.timeline.scrollToTop(); for (const charly of charlies) { - await expect(await app.timeline.findEventTile(charly.credentials.displayName, charlyMsg1)).toBeAttached(); - await expect(await app.timeline.findEventTile(charly.credentials.displayName, charlyMsg2)).toBeAttached(); + await expect( + (await app.timeline.findEventTile(charly.credentials!.displayName!, charlyMsg1))!, + ).toBeAttached(); + await expect( + (await app.timeline.findEventTile(charly.credentials!.displayName!, charlyMsg2))!, + ).toBeAttached(); } } @@ -99,13 +103,13 @@ test.describe("Lazy Loading", () => { await expect(getMemberInMemberlist(page, "Alice")).toBeAttached(); await expect(getMemberInMemberlist(page, "Bob")).toBeAttached(); for (const charly of charlies) { - await expect(getMemberInMemberlist(page, charly.credentials.displayName)).toBeAttached(); + await expect(getMemberInMemberlist(page, charly.credentials!.displayName!)).toBeAttached(); } } async function checkMemberListLacksCharlies(page: Page, charlies: Bot[]) { for (const charly of charlies) { - await expect(getMemberInMemberlist(page, charly.credentials.displayName)).not.toBeAttached(); + await expect(getMemberInMemberlist(page, charly.credentials!.displayName!)).not.toBeAttached(); } } diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-collapse.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-collapse.spec.ts index 73a670ee9f..977b0ffa24 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-collapse.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-collapse.spec.ts @@ -5,17 +5,20 @@ * Please see LICENSE files in the repository root for full details. */ +import { rejectToast } from "@element-hq/element-web-playwright-common"; + import { test, expect } from "../../../element-web-test"; import type { Locator, Page } from "playwright-core"; test.describe("Collapsible Room list", () => { test.use({ displayName: "Alice", + lockLeftPanelWidth: false, }); test.beforeEach(async ({ page, app, user }) => { - await app.closeVerifyToast(); - await app.closeNotificationToast(); + await rejectToast(page, "Verify this device"); + await rejectToast(page, "Notifications"); for (let i = 0; i < 10; i++) { await app.client.createRoom({ name: `room${i}` }); } @@ -30,8 +33,8 @@ test.describe("Collapsible Room list", () => { const boundingBox = await leftPanelLocator.boundingBox(); // Move mouse 2px to the right of the left-panel, this should be region that the user drags to resize the panel. - const mouseX = boundingBox.x + boundingBox.width + 2; - const mouseY = boundingBox.y + boundingBox.height / 2; + const mouseX = boundingBox!.x + boundingBox!.width + 2; + const mouseY = boundingBox!.y + boundingBox!.height / 2; await page.mouse.move(mouseX, mouseY); await page.mouse.down(); @@ -47,12 +50,12 @@ test.describe("Collapsible Room list", () => { // Contract the panel let previousBoundingBox = await resize(page, -50); let currentBoundingBox = await leftPanelLocator.boundingBox(); - expect(currentBoundingBox.width).toBeCloseTo(previousBoundingBox.width - 50, 0); + expect(currentBoundingBox!.width).toBeCloseTo(previousBoundingBox!.width - 50, 0); // Expand the panel previousBoundingBox = await resize(page, 30); currentBoundingBox = await leftPanelLocator.boundingBox(); - expect(currentBoundingBox.width).toBeCloseTo(previousBoundingBox.width + 30, 0); + expect(currentBoundingBox!.width).toBeCloseTo(previousBoundingBox!.width + 30, 0); }); test( @@ -64,7 +67,7 @@ test.describe("Collapsible Room list", () => { // Collapse the panel await resize(page, -300); let currentBoundingBox = await leftPanelLocator.boundingBox(); - expect(currentBoundingBox.width).toStrictEqual(0); + expect(currentBoundingBox!.width).toStrictEqual(0); // Expect te separator to be shown const separator = page.getByRole("separator", { name: "Click or drag to expand" }); @@ -74,19 +77,19 @@ test.describe("Collapsible Room list", () => { // Should be possible to expand by clicking on the separator await separator.click(); currentBoundingBox = await leftPanelLocator.boundingBox(); - expect(currentBoundingBox.width).toBeGreaterThan(365); + expect(currentBoundingBox!.width).toBeGreaterThan(365); // Collapse the panel again await resize(page, -300); // Check that the panel can be expanded by dragging the separator const separatorBoundingBox = await separator.boundingBox(); - const mouseX = separatorBoundingBox.x + separatorBoundingBox.width / 2; - const mouseY = separatorBoundingBox.y + separatorBoundingBox.height / 2; + const mouseX = separatorBoundingBox!.x + separatorBoundingBox!.width / 2; + const mouseY = separatorBoundingBox!.y + separatorBoundingBox!.height / 2; await page.mouse.move(mouseX, mouseY); await page.mouse.down(); await page.mouse.move(mouseX + 400, mouseY); - expect(currentBoundingBox.width).toBeGreaterThan(365); + expect(currentBoundingBox!.width).toBeGreaterThan(365); }, ); }); diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts index e40b1a6c0d..671fb8cc84 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts @@ -6,14 +6,22 @@ */ import { type Page } from "@playwright/test"; +import { closeReleaseAnnouncement, rejectToast } from "@element-hq/element-web-playwright-common"; import { expect, test } from "../../../element-web-test"; -import { getRoomList, getRoomListHeader, getSectionHeader } from "./utils"; +import { + assertRoomInSection, + assertSectionsOrder, + dragRoomToSection, + dragSectionToSection, + getRoomList, + getRoomListHeader, + getSectionHeader, +} from "./utils"; test.describe("Room list custom sections", () => { test.use({ displayName: "Alice", - labsFlags: ["feature_new_room_list", "feature_room_list_sections"], botCreateOpts: { displayName: "BotBob", autoAcceptInvites: true, @@ -40,26 +48,13 @@ test.describe("Room list custom sections", () => { await expect(dialog).not.toBeVisible(); } - /** - * Asserts a room is nested under a specific section using the treegrid aria-level hierarchy. - * Section header rows sit at aria-level=1; room rows nested within a section sit at aria-level=2. - * Verifies that the closest preceding aria-level=1 row is the expected section header. - */ - async function assertRoomInSection(page: Page, sectionName: string, roomName: string): Promise { - const roomList = getRoomList(page); - const roomRow = roomList.getByRole("row", { name: `Open room ${roomName}` }); - // Room row must be at aria-level=2 (i.e. inside a section) - await expect(roomRow).toHaveAttribute("aria-level", "2"); - // The closest preceding aria-level=1 row must be the expected section header. - // XPath preceding:: axis returns nodes before the context in document order; [1] picks the nearest one. - const closestSectionHeader = roomRow.locator(`xpath=preceding::*[@role="row" and @aria-level="1"][1]`); - await expect(closestSectionHeader).toContainText(sectionName); - } - test.beforeEach(async ({ page, app, user }) => { - // The notification toast is displayed above the search section - await app.closeNotificationToast(); - await app.closeVerifyToast(); + // The toasts are displayed above the search section + await rejectToast(page, "Verify this device"); + await rejectToast(page, "Notifications"); + + // Close the release announcement about the new room list sections + await closeReleaseAnnouncement(page, "Introducing Sections"); // Focus the user menu to avoid hover decoration await page.getByRole("button", { name: "User menu" }).focus(); @@ -216,7 +211,7 @@ test.describe("Room list custom sections", () => { // Change the name and confirm await dialog.getByRole("textbox", { name: "Section name" }).fill("Personal"); - await dialog.getByRole("button", { name: "Edit section" }).click(); + await dialog.getByRole("button", { name: "Save" }).click(); // Dialog should close await expect(dialog).not.toBeVisible(); @@ -299,6 +294,33 @@ test.describe("Room list custom sections", () => { }); }); + test.describe("Section reordering via dnd", () => { + test("should reorder custom sections via dnd", async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + await createCustomSection(page, "Work"); + await createCustomSection(page, "Personal"); + + // Default placement: custom sections sit at the top of Chats + await assertSectionsOrder(page, ["Work", "Personal", "Chats"]); + + // Moves Work after Chats + await dragSectionToSection(page, "Work", "Chats"); + await assertSectionsOrder(page, ["Personal", "Chats", "Work"]); + }); + + test("should insert a section before the target when dragging up", async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + await createCustomSection(page, "Work"); + await createCustomSection(page, "Personal"); + + await assertSectionsOrder(page, ["Work", "Personal", "Chats"]); + + // Personal sits below Work, so dragging it onto Work inserts it before Work. + await dragSectionToSection(page, "Personal", "Work"); + await assertSectionsOrder(page, ["Personal", "Work", "Chats"]); + }); + }); + test.describe("Adding a room to a custom section", () => { test("should add a room to a custom section via the More Options menu", async ({ page, app }) => { await app.client.createRoom({ name: "my room" }); @@ -345,6 +367,22 @@ test.describe("Room list custom sections", () => { }, ); + test("should accept drag and drop into a section created after another section exists", async ({ + page, + app, + }) => { + await app.client.createRoom({ name: "room A" }); + await app.client.createRoom({ name: "room B" }); + await createCustomSection(page, "Work"); + await createCustomSection(page, "Personal"); + + await dragRoomToSection(page, "room A", "Personal"); + await assertRoomInSection(page, "Personal", "room A"); + + await dragRoomToSection(page, "room B", "Work"); + await assertRoomInSection(page, "Work", "room B"); + }); + test("should remove a room from a custom section when toggling the same section", async ({ page, app }) => { await app.client.createRoom({ name: "my room" }); await createCustomSection(page, "Work"); @@ -370,5 +408,33 @@ test.describe("Room list custom sections", () => { // Room is back in the Chats section await assertRoomInSection(page, "Chats", "my room"); }); + + test("should remove a room from a custom section via the 'Remove from section' menu entry", async ({ + page, + app, + }) => { + await app.client.createRoom({ name: "my room" }); + await createCustomSection(page, "Work"); + + const roomList = getRoomList(page); + + // Move the room to the Work section + let roomItem = roomList.getByRole("row", { name: "Open room my room" }); + await roomItem.hover(); + await roomItem.getByRole("button", { name: "More Options" }).click(); + await page.getByRole("menuitem", { name: "Move to" }).hover(); + await page.getByRole("menuitem", { name: "Work" }).click(); + + await assertRoomInSection(page, "Work", "my room"); + + // Open the More Options menu and click "Remove from section" + roomItem = roomList.getByRole("row", { name: "Open room my room" }); + await roomItem.hover(); + await roomItem.getByRole("button", { name: "More Options" }).click(); + await page.getByRole("menuitem", { name: "Remove from section" }).click(); + + // Room is back in the Chats section + await assertRoomInSection(page, "Chats", "my room"); + }); }); }); diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts index 495ced07f6..4488d66cc8 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts @@ -7,6 +7,7 @@ import { type Visibility } from "matrix-js-sdk/src/matrix"; import { type Page } from "@playwright/test"; +import { closeReleaseAnnouncement, rejectToast } from "@element-hq/element-web-playwright-common"; import { expect, test } from "../../../element-web-test"; import { SettingLevel } from "../../../../src/settings/SettingLevel"; @@ -19,7 +20,6 @@ test.describe("Room list filters and sort", () => { displayName: "BotBob", autoAcceptInvites: true, }, - labsFlags: ["feature_new_room_list"], }); /** @@ -32,8 +32,11 @@ test.describe("Room list filters and sort", () => { test.beforeEach(async ({ page, app, bot, user }) => { // The toasts are displayed above the search section - await app.closeVerifyToast(); - await app.closeNotificationToast(); + await rejectToast(page, "Verify this device"); + await rejectToast(page, "Notifications"); + + // Close the release announcement about the new room list sections + await closeReleaseAnnouncement(page, "Introducing Sections"); }); test("Tombstoned rooms are not shown even when they receive updates", async ({ page, app, bot }) => { @@ -45,7 +48,7 @@ test.describe("Room list filters and sort", () => { We will also send a simple message in this room. */ const oldRoomId = await app.client.createRoom({ name: "Old Room" }); - await app.client.inviteUser(oldRoomId, bot.credentials.userId); + await app.client.inviteUser(oldRoomId, bot.credentials!.userId); await bot.joinRoom(oldRoomId); const response = await app.client.sendMessage(oldRoomId, "Hello!"); @@ -96,8 +99,8 @@ test.describe("Room list filters and sort", () => { }); test.describe("Room list", () => { - let unReadDmId: string | undefined; - let unReadRoomId: string | undefined; + let unReadDmId: string; + let unReadRoomId: string; test.beforeEach(async ({ page, app, bot, user }) => { await app.client.createRoom({ name: "empty room" }); @@ -111,7 +114,7 @@ test.describe("Room list filters and sort", () => { await bot.sendMessage(unReadDmId, "I am a robot. Beep."); unReadRoomId = await app.client.createRoom({ name: "unread room" }); - await app.client.inviteUser(unReadRoomId, bot.credentials.userId); + await app.client.inviteUser(unReadRoomId, bot.credentials!.userId); await bot.joinRoom(unReadRoomId); await bot.sendMessage(unReadRoomId, "I am a robot. Beep."); @@ -132,7 +135,7 @@ test.describe("Room list filters and sort", () => { }); const mentionRoomId = await app.client.createRoom({ name: "room with mention" }); - await app.client.inviteUser(mentionRoomId, bot.credentials.userId); + await app.client.inviteUser(mentionRoomId, bot.credentials!.userId); await bot.joinRoom(mentionRoomId); const clientBot = await bot.prepareClient(); @@ -176,19 +179,19 @@ test.describe("Room list filters and sort", () => { await expect.poll(() => roomList.locator("role=option").count()).toBe(2); await primaryFilters.getByRole("option", { name: "Rooms" }).click(); - await expect(roomList.getByRole("option", { name: "unread room" })).toBeVisible(); - await expect(roomList.getByRole("option", { name: "favourite room" })).toBeVisible(); - await expect(roomList.getByRole("option", { name: "empty room" })).toBeVisible(); - await expect(roomList.getByRole("option", { name: "room with mention" })).toBeVisible(); - await expect(roomList.getByRole("option", { name: "Low prio room" })).toBeVisible(); - await expect.poll(() => roomList.locator("role=option").count()).toBe(5); + // "Open room" prefix disambiguates the room tile from the "Toggle Chats section with + // unread rooms" section header button, which also matches the "unread room" substring. + await expect(roomList.getByRole("button", { name: "Open room unread room" })).toBeVisible(); + await expect(roomList.getByRole("button", { name: "favourite room" })).toBeVisible(); + await expect(roomList.getByRole("button", { name: "empty room" })).toBeVisible(); + await expect(roomList.getByRole("button", { name: "room with mention" })).toBeVisible(); + await expect(roomList.getByRole("button", { name: "Low prio room" })).toBeVisible(); + // 5 room tiles spread across 3 sections (Favourites, Rooms, Low priority); each section + // header is also a button, so 5 rooms + 3 section headers = 8 buttons. + await expect.poll(() => roomList.locator("role=button").count()).toBe(8); await getFilterExpandButton(page).click(); - await primaryFilters.getByRole("option", { name: "Favourite" }).click(); - await expect(roomList.getByRole("option", { name: "favourite room" })).toBeVisible(); - await expect.poll(() => roomList.locator("role=option").count()).toBe(1); - await primaryFilters.getByRole("option", { name: "Mentions" }).click(); await expect(roomList.getByRole("option", { name: "room with mention" })).toBeVisible(); await expect.poll(() => roomList.locator("role=option").count()).toBe(1); @@ -211,11 +214,11 @@ test.describe("Room list filters and sort", () => { // Let's configure unread dm room so that we only get notification for mentions and keywords await app.viewRoomById(unReadDmId); await app.settings.openRoomSettings("Notifications"); - await page.getByText("@mentions & keywords").click(); + await page.getByText("@mentions and replies").click(); await app.settings.closeDialog(); // Let's open a room other than unread room or unread dm - await roomListView.getByRole("option", { name: "Open room favourite room" }).click(); + await roomListView.getByRole("button", { name: "Open room favourite room" }).click(); // Let's make the bot send a new message in both rooms await bot.sendMessage(unReadDmId, "Hello!"); @@ -238,15 +241,20 @@ test.describe("Room list filters and sort", () => { await getRoomOptionsMenu(page).click(); await page.getByRole("menuitemradio", { name: "A-Z" }).click(); - await expect(roomListView.getByRole("option").first()).toHaveText(/empty room/); + // Favourite + chat section headers are buttons + favourite room + await expect(roomListView.getByRole("button").nth(3)).toHaveText(/empty room/); }); - test("should move room to the top on message when sorting by activity", async ({ page, bot }) => { + test("should move room to the top on message (chat section) when sorting by activity", async ({ + page, + bot, + }) => { const roomListView = getRoomList(page); await bot.sendMessage(unReadDmId, "Hello!"); - await expect(roomListView.getByRole("option").first()).toHaveText(/unread dm/); + // Favourite + chat section headers are buttons + favourite room + await expect(roomListView.getByRole("button").nth(3)).toHaveText(/unread dm/); }); }); @@ -301,7 +309,7 @@ test.describe("Room list filters and sort", () => { ); }); - ["People", "Rooms", "Favourite"].forEach((filter) => { + ["People", "Rooms"].forEach((filter) => { test( `should render the placeholder for ${filter} filter`, { tag: "@screenshot" }, diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts index 471311c3f6..5f94abc19d 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts @@ -5,18 +5,19 @@ * Please see LICENSE files in the repository root for full details. */ +import { closeReleaseAnnouncement, rejectToast } from "@element-hq/element-web-playwright-common"; + import { test, expect } from "../../../element-web-test"; import { getHeaderSection } from "./utils"; test.describe("Header section of the room list", () => { - test.use({ - labsFlags: ["feature_new_room_list"], - }); - test.beforeEach(async ({ page, app, user }) => { // The toasts are displayed above the search section - await app.closeVerifyToast(); - await app.closeNotificationToast(); + await rejectToast(page, "Verify this device"); + await rejectToast(page, "Notifications"); + + // Close the release announcement about the new room list sections + await closeReleaseAnnouncement(page, "Introducing Sections"); }); test("should render the header section", { tag: "@screenshot" }, async ({ page, app, user }) => { diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts index eae61c8bab..1630e44a4c 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts @@ -5,18 +5,20 @@ * Please see LICENSE files in the repository root for full details. */ +import { rejectToast } from "@element-hq/element-web-playwright-common"; + import { test, expect } from "../../../element-web-test"; import { getRoomListView } from "./utils"; test.describe("Room list panel", () => { test.use({ - labsFlags: ["feature_new_room_list"], + displayName: "Eve", }); test.beforeEach(async ({ page, app, user }) => { // The toasts are displayed above the search section - await app.closeVerifyToast(); - await app.closeNotificationToast(); + await rejectToast(page, "Verify this device"); + await rejectToast(page, "Notifications"); // Populate the room list for (let i = 0; i < 20; i++) { @@ -34,9 +36,11 @@ test.describe("Room list panel", () => { await expect(roomListView).toMatchScreenshot("room-list-panel.png"); }); - test("should respond to small screen sizes", { tag: "@screenshot" }, async ({ page }) => { - await page.setViewportSize({ width: 575, height: 600 }); - const roomListPanel = getRoomListView(page); - await expect(roomListPanel).toMatchScreenshot("room-list-panel-smallscreen.png"); + test.describe("small screen", () => { + test.use({ lockLeftPanelWidth: false }); + test("should respond to small screen sizes", { tag: "@screenshot" }, async ({ page }) => { + await page.setViewportSize({ width: 575, height: 600 }); + await expect(page).toMatchScreenshot("room-list-panel-smallscreen.png"); + }); }); }); diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-search.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-search.spec.ts index 492b3f429d..665d2431d2 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-search.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-search.spec.ts @@ -5,18 +5,16 @@ * Please see LICENSE files in the repository root for full details. */ +import { rejectToast } from "@element-hq/element-web-playwright-common"; + import { test, expect } from "../../../element-web-test"; import { getSearchSection } from "./utils"; test.describe("Search section of the room list", () => { - test.use({ - labsFlags: ["feature_new_room_list"], - }); - test.beforeEach(async ({ page, app, user }) => { // The toasts are displayed above the search section - await app.closeVerifyToast(); - await app.closeNotificationToast(); + await rejectToast(page, "Verify this device"); + await rejectToast(page, "Notifications"); }); test("should render the search section", { tag: "@screenshot" }, async ({ page, app, user }) => { diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts index d8c12f55da..5e36ff01a2 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts @@ -5,13 +5,15 @@ * Please see LICENSE files in the repository root for full details. */ +import { rejectToast, rejectToastIfExists } from "@element-hq/element-web-playwright-common"; + import { expect, test } from "../../../element-web-test"; -import { getPrimaryFilters, getRoomList, getSectionHeader } from "./utils"; +import { SettingLevel } from "../../../../src/settings/SettingLevel"; +import { assertRoomInSection, dragRoomToSection, getPrimaryFilters, getRoomList, getSectionHeader } from "./utils"; test.describe("Room list sections", () => { test.use({ displayName: "Alice", - labsFlags: ["feature_new_room_list", "feature_room_list_sections"], botCreateOpts: { displayName: "BotBob", autoAcceptInvites: true, @@ -20,8 +22,8 @@ test.describe("Room list sections", () => { test.beforeEach(async ({ page, app, user }) => { // The toasts are displayed above the search section - await app.closeVerifyToast(); - await app.closeNotificationToast(); + await rejectToast(page, "Verify this device"); + await rejectToast(page, "Notifications"); // focus the user menu to avoid to have hover decoration await page.getByRole("button", { name: "User menu" }).focus(); @@ -88,6 +90,85 @@ test.describe("Room list sections", () => { }); }); + test.describe("Show sections setting", () => { + test.beforeEach(async ({ app }) => { + // A favourite room and a regular room so that, when sections are enabled, we get + // two meaningful sections (Favourites + Chats). + const favouriteId = await app.client.createRoom({ name: "favourite room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId); + await app.client.createRoom({ name: "regular room" }); + }); + + test("toggling RoomList.showSections switches between a sectioned and a flat list", async ({ page, app }) => { + const roomList = getRoomList(page); + + // Sections are enabled by default: section headers are visible and rooms render as treegrid rows. + await expect(getSectionHeader(page, "Favourites")).toBeVisible(); + await expect(getSectionHeader(page, "Chats")).toBeVisible(); + await expect(roomList.getByRole("row", { name: "Open room favourite room" })).toBeVisible(); + + // Disable sections + await app.settings.setValue("RoomList.showSections", null, SettingLevel.ACCOUNT, false); + + // The list becomes flat: no section headers, rooms render as listbox options. + await expect(getSectionHeader(page, "Favourites")).not.toBeVisible(); + await expect(getSectionHeader(page, "Chats")).not.toBeVisible(); + await expect(page.getByRole("listbox", { name: "Room list", exact: true })).toBeVisible(); + await expect(roomList.getByRole("option", { name: "Open room favourite room" })).toBeVisible(); + await expect(roomList.getByRole("option", { name: "Open room regular room" })).toBeVisible(); + + // Re-enable sections + await app.settings.setValue("RoomList.showSections", null, SettingLevel.ACCOUNT, true); + + // The sections reappear. + await expect(getSectionHeader(page, "Favourites")).toBeVisible(); + await expect(getSectionHeader(page, "Chats")).toBeVisible(); + await expect(roomList.getByRole("row", { name: "Open room favourite room" })).toBeVisible(); + }); + }); + + test.describe("Filters when sections are disabled", () => { + test.beforeEach(async ({ app }) => { + await app.settings.setValue("RoomList.showSections", null, SettingLevel.ACCOUNT, false); + + // A favourite room, a low priority room, and a regular room. + const favouriteId = await app.client.createRoom({ name: "favourite room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId); + const lowPrioId = await app.client.createRoom({ name: "low prio room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.lowpriority"); + }, lowPrioId); + await app.client.createRoom({ name: "regular room" }); + }); + + test("shows the Favourites and Low Priority filters and filters the flat list", async ({ page }) => { + const roomList = getRoomList(page); + const primaryFilters = getPrimaryFilters(page); + + // Expand the filter list to reveal all filters + await primaryFilters.getByRole("button", { name: "Expand filter list" }).click(); + + // The Favourites and Low Priority filters are available again when sections are disabled + await expect(primaryFilters.getByRole("option", { name: "Favourites" })).toBeVisible(); + await expect(primaryFilters.getByRole("option", { name: "Low priority" })).toBeVisible(); + + // Filtering by Favourites shows only the favourite room + await primaryFilters.getByRole("option", { name: "Favourites" }).click(); + await expect(roomList.getByRole("option", { name: "Open room favourite room" })).toBeVisible(); + await expect(roomList.getByRole("option", { name: "Open room regular room" })).not.toBeVisible(); + await expect(roomList.getByRole("option", { name: "Open room low prio room" })).not.toBeVisible(); + + // Switching to the Low Priority filter shows only the low priority room + await primaryFilters.getByRole("option", { name: "Low priority" }).click(); + await expect(roomList.getByRole("option", { name: "Open room low prio room" })).toBeVisible(); + await expect(roomList.getByRole("option", { name: "Open room favourite room" })).not.toBeVisible(); + }); + }); + test.describe("Section collapse and expand", () => { [ { section: "Favourites", roomName: "favourite room", tag: "m.favourite" }, @@ -148,6 +229,63 @@ test.describe("Room list sections", () => { }); }); + test.describe("Section collapse state persistence", () => { + test.beforeEach(async ({ app }) => { + // A favourite room (so we get a Favourites section) and a regular room in Chats, + // giving us two independent sections whose expansion state we can assert. + const favouriteId = await app.client.createRoom({ name: "favourite room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId); + await app.client.createRoom({ name: "regular room" }); + }); + + test("persists the collapsed/expanded state across reloads", async ({ page }) => { + const roomList = getRoomList(page); + const favouritesHeader = getSectionHeader(page, "Favourites"); + const chatsHeader = getSectionHeader(page, "Chats"); + const favRoom = roomList.getByRole("row", { name: "Open room favourite room" }); + const regularRoom = roomList.getByRole("row", { name: "Open room regular room" }); + + // Collapse both the Favourites and Chats sections + await expect(favouritesHeader).toHaveAttribute("aria-expanded", "true"); + await favouritesHeader.click(); + await expect(favouritesHeader).toHaveAttribute("aria-expanded", "false"); + await expect(favRoom).not.toBeVisible(); + + await expect(chatsHeader).toHaveAttribute("aria-expanded", "true"); + await chatsHeader.click(); + await expect(chatsHeader).toHaveAttribute("aria-expanded", "false"); + await expect(regularRoom).not.toBeVisible(); + + // Reload the page: the collapsed state is persisted at the device level and should survive + await page.reload(); + await rejectToastIfExists(page, "Verify this device"); + await rejectToastIfExists(page, "Notifications"); + + // Both sections are still collapsed and their rooms stay hidden + await expect(getSectionHeader(page, "Favourites")).toHaveAttribute("aria-expanded", "false"); + await expect(getRoomList(page).getByRole("row", { name: "Open room favourite room" })).not.toBeVisible(); + await expect(getSectionHeader(page, "Chats")).toHaveAttribute("aria-expanded", "false"); + await expect(getRoomList(page).getByRole("row", { name: "Open room regular room" })).not.toBeVisible(); + + // Expand them again and reload: the expanded state is likewise persisted + await getSectionHeader(page, "Favourites").click(); + await expect(getSectionHeader(page, "Favourites")).toHaveAttribute("aria-expanded", "true"); + await getSectionHeader(page, "Chats").click(); + await expect(getSectionHeader(page, "Chats")).toHaveAttribute("aria-expanded", "true"); + + await page.reload(); + await rejectToastIfExists(page, "Verify this device"); + await rejectToastIfExists(page, "Notifications"); + + await expect(getSectionHeader(page, "Favourites")).toHaveAttribute("aria-expanded", "true"); + await expect(getRoomList(page).getByRole("row", { name: "Open room favourite room" })).toBeVisible(); + await expect(getSectionHeader(page, "Chats")).toHaveAttribute("aria-expanded", "true"); + await expect(getRoomList(page).getByRole("row", { name: "Open room regular room" })).toBeVisible(); + }); + }); + test.describe("Rooms placement in sections", () => { test("should move a room between sections when tags change", async ({ page, app }) => { await app.client.createRoom({ name: "my room" }); @@ -182,31 +320,125 @@ test.describe("Room list sections", () => { roomItem = roomList.getByRole("row", { name: "Open room my room" }); await expect(roomItem).toBeVisible(); }); + + test("should move a room from Chats to Favourites when using dnd", async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + + const favouriteId = await app.client.createRoom({ name: "favourite room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId); + + await dragRoomToSection(page, "my room", "Favourites"); + await assertRoomInSection(page, "Favourites", "my room"); + }); + + test("should move a room from Favourites to Chats when using dnd", async ({ page, app }) => { + const favouriteId = await app.client.createRoom({ name: "my room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId); + + // Create a second favourite room to ensure we stay in section mode (not flat list) + const favouriteId2 = await app.client.createRoom({ name: "favourite room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId2); + + // Ensure the Chats section is visible by creating a room in it + await app.client.createRoom({ name: "room in chats" }); + + await dragRoomToSection(page, "my room", "Chats"); + await assertRoomInSection(page, "Chats", "my room"); + }); }); - test("should show unread indicator on section header", async ({ page, app, bot }) => { - // Create a favourite room - const favouriteId = await app.client.createRoom({ name: "favourite room" }); - await app.client.evaluate(async (client, roomId) => { - await client.setRoomTag(roomId, "m.favourite"); - }, favouriteId); + test.describe("Section header notification", () => { + test("should show unread indicator on section header", async ({ page, app, bot }) => { + // Create a favourite room + const favouriteId = await app.client.createRoom({ name: "favourite room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId); - const roomList = getRoomList(page); + const roomList = getRoomList(page); - // Invite the bot and have it send a message to generate an unread - await app.client.inviteUser(favouriteId, bot.credentials.userId); - await bot.joinRoom(favouriteId); - await bot.sendMessage(favouriteId, "Hello from bot!"); + // Invite the bot and have it send a message to generate an unread + await app.client.inviteUser(favouriteId, bot.credentials!.userId); + await bot.joinRoom(favouriteId); + await bot.sendMessage(favouriteId, "Hello from bot!"); - let sectionHeader = getSectionHeader(page, "Favourites", true); - await expect(sectionHeader).toBeVisible(); + let sectionHeader = getSectionHeader(page, "Favourites", true); + await expect(sectionHeader).toBeVisible(); - // Open the room to mark it as read - await roomList.getByRole("row", { name: "Open room favourite room" }).click(); + // Open the room to mark it as read + await roomList.getByRole("row", { name: "Open room favourite room" }).click(); - // The section should no longer be unread - sectionHeader = getSectionHeader(page, "Favourites", false); - await expect(sectionHeader).toBeVisible(); + // The section should no longer be unread + sectionHeader = getSectionHeader(page, "Favourites", false); + await expect(sectionHeader).toBeVisible(); + }); + + test( + "should aggregate notification decorations on the collapsed section header", + { tag: "@screenshot" }, + async ({ page, app, user, bot }) => { + // A favourite room to keep the room list in section mode (otherwise it renders as a flat list) + const favouriteId = await app.client.createRoom({ name: "favourite room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId); + + // A room with a mention, landing in the Chats section + const mentionId = await app.client.createRoom({ name: "mention room" }); + await app.client.inviteUser(mentionId, bot.credentials!.userId); + await bot.joinRoom(mentionId); + const clientBot = await bot.prepareClient(); + await clientBot.evaluate( + async (client, { roomId, userId }) => { + await client.sendMessage(roomId, { + // @ts-ignore ignore usage of MsgType.text + "msgtype": "m.text", + "body": "User", + "format": "org.matrix.custom.html", + "formatted_body": `User`, + "m.mentions": { + user_ids: [userId], + }, + }); + }, + { roomId: mentionId, userId: user.userId }, + ); + + // A room we are invited to, landing in the Chats section + await bot.createRoom({ + name: "invited room", + invite: [user.userId], + is_direct: true, + }); + + const roomList = getRoomList(page); + + // Wait for the mention decoration to sync onto the mention room before collapsing, so the + // section header aggregation has the room states available. + await expect( + roomList.getByRole("row", { name: /mention room/ }).getByTestId("notification-decoration"), + ).toBeVisible(); + + // Collapse the Chats section so the aggregated decoration is displayed on its header + const chatsHeader = getSectionHeader(page, "Chats", true); + await expect(chatsHeader).toBeVisible(); + await chatsHeader.click(); + + // The header hides its decoration while hovered/focused, so move the pointer away + await page.mouse.move(0, 0); + + // The collapsed header aggregates the mention and the invitation + await expect(chatsHeader.getByTestId("notification-decoration")).toBeVisible(); + + await expect(chatsHeader).toMatchScreenshot("room-list-section-header-notification.png"); + }, + ); }); test.describe("Sections and filters interaction", () => { @@ -232,13 +464,13 @@ test.describe("Room list sections", () => { await app.client.evaluate(async (client, roomId) => { await client.setRoomTag(roomId, "m.favourite"); }, favouriteId); - await app.client.inviteUser(favouriteId, bot.credentials.userId); + await app.client.inviteUser(favouriteId, bot.credentials!.userId); await bot.joinRoom(favouriteId); await bot.sendMessage(favouriteId, "Hello from favourite!"); // Create a regular room with unread messages const regularId = await app.client.createRoom({ name: "regular with unread" }); - await app.client.inviteUser(regularId, bot.credentials.userId); + await app.client.inviteUser(regularId, bot.credentials!.userId); await bot.joinRoom(regularId); await bot.sendMessage(regularId, "Hello from regular!"); @@ -257,4 +489,131 @@ test.describe("Room list sections", () => { await expect(roomList.getByRole("row", { name: "no unread room" })).not.toBeVisible(); }); }); + + test.describe("Section keyboard navigation", () => { + test.beforeEach(async ({ app }) => { + // A favourite room forces section mode and gives us a non-trivial first section. + const favouriteId = await app.client.createRoom({ name: "favourite room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId); + + // A chats-section room so we have a second section to navigate to. + await app.client.createRoom({ name: "chat room" }); + }); + + test("Arrow Down/Up move focus through sections and rooms", async ({ page }) => { + const roomList = getRoomList(page); + const favouritesHeader = getSectionHeader(page, "Favourites"); + // In treegrid mode, a room renders as
. + // Only the inner