Compare commits

...

48 Commits

Author SHA1 Message Date
RiotRobot ec137cb5fb v39.4.0 2025-12-16 13:50:28 +00:00
RiotRobot a696e77652 v39.4.0-rc.0 2025-12-09 14:51:21 +00:00
Andy Balaam 582a76d87c Update encrypted state to say MSC4362 everywhere (#5079)
* Update encrypted state to say MSC4362 everywhere

* Fix test failure with encrypted state: handle empty string state key
2025-12-09 12:47:38 +00:00
Skye Elliot fdfddde55a Import room key bundles received after invite. (#5080)
* feat: Import room key bundles when received after invite.

* tests: Add spec test for room key bundle arriving after invite accepted.

* chore: Fix code quality issue (unnecessary async function).

* docs: Tidy up comments.

* refactor: Simplify key bundle importing after invite to one entrypoint.

- Remove `onReceiveToDeviceEvent` from `CryptoBackend`.
- Copy old room key bundle importing logic to
  `preprocessToDeviceEvents`.

* refactor: Move late bundle importing to main preprocess loop.

* fix: Use `Map` over `Record` to prevent prototype pollution.
2025-12-08 17:50:13 +00:00
Michael Telatynski 0ecfef2352 Update SonarCloud workflow action version 2025-12-04 13:22:19 +00:00
Michael Telatynski 4fdece6c1c Update SonarCloud action version in workflow 2025-12-04 13:14:18 +00:00
Michael Telatynski 6f0bce8708 Add label to skip Sonar coverage (#5094)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-12-04 12:22:38 +00:00
Michael Telatynski d3bdeb73f5 Avoid use of Optional type (#5093)
* Avoid use of Optional type

As we are likely to remove dependency on matrix-events-sdk

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Tweak params

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Prettier

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update test

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-12-04 11:27:43 +00:00
RiotRobot 942fdf5bee Merge branch 'master' into develop 2025-12-02 14:45:10 +00:00
RiotRobot dd2635dbe6 v39.3.0 2025-12-02 14:44:30 +00:00
Will Hunt 3d1bcb73c1 Allow msc4354_sticky_key to be optional on sticky events. (#5073) 2025-11-27 11:54:49 +00:00
Michael Telatynski a960e686b3 Handle all response fields from /context API being optional (#5089)
* Handle all response fields from /context API being optional

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Simplify

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-11-27 11:12:39 +00:00
Michael Telatynski 946774c3fb Fix close-if-fork-develop job permissions (#5088)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-11-26 10:27:02 +00:00
renovate[bot] 15edbc8067 Update dependency @stylistic/eslint-plugin to v5.6.1 (#5083)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 15:48:46 +00:00
renovate[bot] 1398ac24a2 Update typescript-eslint monorepo to v8.47.0 (#5086)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 15:48:38 +00:00
renovate[bot] c76df4cd8f Update all non-major dependencies (#5082)
* Update all non-major dependencies

* Make knip happy

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-11-25 15:43:23 +00:00
renovate[bot] a5e4dbf2d3 Update dependency matrix-widget-api to v1.15.0 (#5084)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 14:58:14 +00:00
renovate[bot] 3768187395 Update eslint-plugins (#5085)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 14:58:04 +00:00
renovate[bot] 08d0ce25f1 Update actions/checkout action to v6 (#5087)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 14:57:19 +00:00
RiotRobot 23241f18e2 v39.3.0-rc.0 2025-11-25 14:10:43 +00:00
Michael Telatynski 90da67aa95 Re-add truthy check on room name/avatar/alias events (#5081)
* Re-add truthy check on room name/avatar/alias events

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add regression test

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-11-25 09:09:11 +00:00
renovate[bot] 0bf2702149 Update jest to v30 (major) (#4875)
* Update jest to v30

* Update snapshots & imports

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Make jest happier

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix tests

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-11-24 09:07:13 +00:00
RiotRobot c7a75c8824 Merge branch 'master' into develop 2025-11-18 14:27:42 +00:00
RiotRobot 98b2b9745d v39.2.0 2025-11-18 14:26:56 +00:00
Michael Telatynski 65d5b3172c Fix invalid state events corrupting room objects (#5078)
* Fix invalid room name/canonical alias corrupting room objects

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add test

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-11-18 10:46:24 +00:00
renovate[bot] 2f72f9e889 Update dependency eslint-plugin-jsdoc to v61.1.12 (#5074)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-11 14:30:01 +00:00
renovate[bot] 18f500a1f8 Update all non-major dependencies (#5076)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-11 14:01:33 +00:00
renovate[bot] b1df58796a Update typescript-eslint monorepo to v8.46.3 (#5075)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-11 14:01:12 +00:00
RiotRobot 761b3771d6 v39.2.0-rc.0 2025-11-11 12:44:05 +00:00
Andrew Ferrazzutti df88edfda0 Delayed event management: split endpoints, no auth (#5066)
* Delayed event management: split endpoints, no auth

Add dedicated endpoints for each of the cancel/restart/send actions for
updating a delayed event, and make them unauthenticated.

Also keep support for the original endpoint where the update action is
in the request body, and make the split-endpoint versions fall back to
it if they are unsupported by the homeserver.

* Don't @link parameters in method docstrings

as TypeDoc doesn't support that

* Reduce code duplication

* Reduce code duplication again

* Add a little more test coverage

* Use split delayed event management for widgets

* Specify which eslint rule to ignore

Co-authored-by: Will Hunt <2072976+Half-Shot@users.noreply.github.com>

* Restore embedded non-split delay evt update method

Keep supporting it to not break widgets that currently use it.
Also add back the test for it.

* Deprecate the non-split delay evt update methods

* Comment to explain fallback to non-split endpoint

* Add backwards compatibility with authed endpoints

* Comment backwards compatibility helper method

* Await returned promises

because `return await promise` is at least as fast as `return promise`

---------

Co-authored-by: Will Hunt <2072976+Half-Shot@users.noreply.github.com>
2025-11-11 05:54:33 +00:00
David Langley 1dee1ba581 Fix media switching during legacy calls (#5069)
* Specify exact for deviceId

* Update mediaHandler.spec.ts

* fallback to ideal if exact fails

* Reduce cognitive complexity for sonar

* Add tests
2025-11-10 09:36:32 +00:00
pkuzco b274c74a30 do not set cache in authenticated fetch (#5020)
* do not set cache in authenticated fetch

* issue 5019 - updated comment

* do not set cache in authenticated fetch

Signed-off-by: Bahaa Naamneh <b.naamneh@gmail.com>

* issue 5019 - updated comment

Signed-off-by: Bahaa Naamneh <b.naamneh@gmail.com>

---------

Signed-off-by: Bahaa Naamneh <b.naamneh@gmail.com>
Co-authored-by: Bahaa Naamneh <glimm.no@gmail.com>
2025-11-06 14:36:27 +00:00
RiotRobot b489bb15cf Merge branch 'master' into develop 2025-11-04 14:02:00 +00:00
RiotRobot dff4922a42 v39.1.2 2025-11-04 14:01:14 +00:00
Michael Telatynski dc6ad0b54c Fix npm publish script (#5068)
The `id` is also an output from this workflow so needs re-adding, not sure how the token stuck around, I thought I removed that
2025-11-04 13:33:55 +00:00
RiotRobot 9769c05dc5 Merge branch 'master' into develop 2025-11-04 13:23:41 +00:00
RiotRobot dd379d3d4c v39.1.1 2025-11-04 13:22:58 +00:00
Michael Telatynski 1b884a3e52 Fix npm release missing template braces (#5067) 2025-11-04 13:00:29 +00:00
RiotRobot ddb164490e Merge branch 'master' into develop 2025-11-04 12:54:27 +00:00
Michael Telatynski 4cc4c01dd8 Remove call to npm dist-tag (#5065)
As it does not work for NPM Trusted Publishing

https://github.com/npm/cli/issues/8547

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-10-30 09:34:14 +00:00
Michael Telatynski 0ae483ce27 Use NPM Trusted Publishers over token (#5064)
* Use NPM Trusted Publishers over token

due to security changes being enacted next month by npm

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update npm as ubuntu-latest only has 10.x and 11.5.1 or later is necessary

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-10-29 16:07:11 +00:00
renovate[bot] dbc1fa87ed Update dependency eslint-plugin-jsdoc to v61.1.8 (#5058)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 23:03:02 +00:00
renovate[bot] 0a3675b971 Update typescript-eslint monorepo to v8.46.2 (#5059)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 16:11:40 +00:00
renovate[bot] ab3f529d29 Update babel monorepo to v7.28.5 (#5057)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 14:57:44 +00:00
renovate[bot] 607b712a07 Update dependency sdp-transform to v3 (#5062)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 14:54:32 +00:00
renovate[bot] b6d9e49277 Update dependency @stylistic/eslint-plugin to v5.5.0 (#5061)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 14:54:17 +00:00
renovate[bot] 731d5943e2 Update GitHub Artifact Actions (#5063)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 14:54:08 +00:00
renovate[bot] 01e7a43593 Update all non-major dependencies (#5060)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 14:53:35 +00:00
56 changed files with 2566 additions and 1807 deletions
+3
View File
@@ -41,3 +41,6 @@
- name: "Z-Flaky-Test"
description: "A test is raising false alarms"
color: "ededed"
- name: "Z-Skip-Coverage"
description: "Skip SonarQube coverage for this PR"
color: "ededed"
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
deployments: write
steps:
- name: 📥 Download artifact
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
+2
View File
@@ -73,6 +73,8 @@ jobs:
close-if-fork-develop:
name: Forbid develop branch fork contributions
runs-on: ubuntu-24.04
permissions:
pull-requests: write
if: >
github.event.action == 'opened' &&
github.event.pull_request.head.ref == 'develop' &&
@@ -16,7 +16,7 @@ jobs:
contents: write
steps:
- name: 🧮 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
with:
ref: staging
fetch-depth: 0
@@ -37,7 +37,7 @@ jobs:
disable-autolabeler: true
- name: Get actions scripts
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
with:
repository: matrix-org/matrix-js-sdk
persist-credentials: false
+2 -2
View File
@@ -18,14 +18,14 @@ jobs:
merge:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
with:
# We will be pushing to this branch and want the CI to run after we do so we cannot use the GITHUB_TOKEN
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
fetch-depth: 0
- name: Get actions scripts
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
with:
repository: matrix-org/matrix-js-sdk
persist-credentials: false
+2 -6
View File
@@ -4,8 +4,6 @@ on:
secrets:
ELEMENT_BOT_TOKEN:
required: true
NPM_TOKEN:
required: false
GPG_PASSPHRASE:
required: false
GPG_PRIVATE_KEY:
@@ -73,7 +71,7 @@ jobs:
draft: true
latest: true
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
with:
ref: staging
# We will be pushing to this branch and want the CI to run after we do so we cannot use the GITHUB_TOKEN
@@ -81,7 +79,7 @@ jobs:
fetch-depth: 0
- name: Get actions scripts
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
with:
repository: matrix-org/matrix-js-sdk
persist-credentials: false
@@ -282,8 +280,6 @@ jobs:
permissions:
contents: read
id-token: write
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
post-release:
name: Post release steps
+7 -13
View File
@@ -1,9 +1,6 @@
name: Publish to npm
on:
workflow_call:
secrets:
NPM_TOKEN:
required: true
outputs:
id:
description: "The npm package@version string we published"
@@ -20,7 +17,7 @@ jobs:
id: ${{ steps.npm-publish.outputs.id }}
steps:
- name: 🧮 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
with:
ref: staging
@@ -31,21 +28,18 @@ jobs:
registry-url: "https://registry.npmjs.org"
node-version-file: package.json
# Ensure npm 11.5.1 or later is installed
- name: Update npm
run: npm install -g npm@latest
- name: 🔨 Install dependencies
run: "yarn install --frozen-lockfile"
- name: 🚀 Publish to npm
id: npm-publish
run: |
npm publish --provenance --access public --tag next
npm publish --provenance --access public --tag "$TAG"
release=$(jq -r '"\(.name)@\(.version)"' package.json)
echo "id=$release" >> $GITHUB_OUTPUT
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: 🎖️ Add `latest` dist-tag to final releases
if: steps.npm-publish.outputs.id && !contains(steps.npm-publish.outputs.id, '-rc.')
run: npm dist-tag add "$release" latest
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
release: ${{ steps.npm-publish.outputs.id }}
TAG: ${{ contains(steps.npm-publish.outputs.id, '-rc.') && 'next' || 'latest' }}
+2 -2
View File
@@ -44,7 +44,7 @@ jobs:
repo:
- element-hq/element-web
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
with:
repository: ${{ matrix.repo }}
ref: staging
@@ -73,7 +73,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: 🧮 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- name: 🔧 Yarn cache
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
+5 -4
View File
@@ -36,14 +36,14 @@ jobs:
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: "🧮 Checkout code"
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
with:
repository: ${{ github.event.workflow_run.head_repository.full_name }}
ref: ${{ github.event.workflow_run.head_branch }} # checkout commit that triggered this workflow
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: 📥 Download artifact
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
if: ${{ !inputs.sharded }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -51,7 +51,7 @@ jobs:
name: coverage
path: coverage
- name: 📥 Download sharded artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
if: inputs.sharded
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -75,13 +75,14 @@ jobs:
- name: "🩻 SonarCloud Scan"
id: sonarcloud
uses: matrix-org/sonarcloud-workflow-action@820f7c2e9e94ba9e35add0f739691e5c7e23fa25 # v4.0
uses: matrix-org/sonarcloud-workflow-action@035ca8a2a741dd940f2e6c16d7d078312d625ac6 # v4.0
# workflow_run fails report against the develop commit always, we don't want that for PRs
continue-on-error: ${{ github.event.workflow_run.head_branch != 'develop' }}
with:
skip_checkout: true
repository: ${{ github.event.workflow_run.head_repository.full_name }}
is_pr: ${{ github.event.workflow_run.event == 'pull_request' }}
skip_coverage_label: Z-Skip-Coverage
version_cmd: "cat package.json | jq -r .version"
branch: ${{ github.event.workflow_run.head_branch }}
revision: ${{ github.event.workflow_run.head_sha }}
+8 -8
View File
@@ -14,7 +14,7 @@ jobs:
name: "Typescript Syntax Check"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
with:
@@ -31,7 +31,7 @@ jobs:
name: "ESLint"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
with:
@@ -48,7 +48,7 @@ jobs:
name: "Node.js example"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
with:
@@ -83,7 +83,7 @@ jobs:
name: "Workflow Lint"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
with:
@@ -100,7 +100,7 @@ jobs:
name: "JSDoc Checker"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
with:
@@ -114,7 +114,7 @@ jobs:
run: "yarn run gendoc --treatWarningsAsErrors --suppressCommentWarningsInDeclarationFiles"
- name: Upload Artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
with:
name: docs
path: _docs
@@ -125,7 +125,7 @@ jobs:
name: "Analyse Dead Code"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
with:
@@ -143,7 +143,7 @@ jobs:
if: github.event_name == 'merge_group'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
with:
repository: element-hq/element-web
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
node: ["lts/*", 22]
steps:
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- name: Setup Node
id: setupNode
@@ -57,7 +57,7 @@ jobs:
- name: Upload Artifact
if: env.ENABLE_COVERAGE == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
with:
name: coverage-${{ matrix.specs }}-${{ matrix.node == 'lts/*' && 'lts' || matrix.node }}
path: |
+42
View File
@@ -1,3 +1,45 @@
Changes in [39.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.4.0) (2025-12-16)
==================================================================================================
## ✨ Features
* Import room key bundles received after invite. ([#5080](https://github.com/matrix-org/matrix-js-sdk/pull/5080)). Contributed by @kaylendog.
## 🐛 Bug Fixes
* Allow msc4354\_sticky\_key to be optional on sticky events. ([#5073](https://github.com/matrix-org/matrix-js-sdk/pull/5073)). Contributed by @Half-Shot.
* Handle all response fields from /context API being optional ([#5089](https://github.com/matrix-org/matrix-js-sdk/pull/5089)). Contributed by @t3chguy.
Changes in [39.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.3.0) (2025-12-02)
==================================================================================================
## 🐛 Bug Fixes
* Re-add truthy check on room name/avatar/alias events ([#5081](https://github.com/matrix-org/matrix-js-sdk/pull/5081)). Contributed by @t3chguy.
* Fix invalid state events corrupting room objects ([#5078](https://github.com/matrix-org/matrix-js-sdk/pull/5078)). Contributed by @t3chguy.
Changes in [39.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.2.0) (2025-11-18)
==================================================================================================
## ✨ Features
* Delayed event management: split endpoints, no auth ([#5066](https://github.com/matrix-org/matrix-js-sdk/pull/5066)). Contributed by @AndrewFerr.
* do not set cache in authenticated fetch ([#5020](https://github.com/matrix-org/matrix-js-sdk/pull/5020)). Contributed by @pkuzco.
## 🐛 Bug Fixes
* Fix media switching during legacy calls ([#5069](https://github.com/matrix-org/matrix-js-sdk/pull/5069)). Contributed by @langleyd.
Changes in [39.1.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.1.2) (2025-11-04)
==================================================================================================
Re-release of v39.1.0 to fix npm publishing workflow
Changes in [39.1.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.1.1) (2025-11-04)
==================================================================================================
Re-release of v39.1.0 to fix npm publishing workflow
Changes in [39.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.1.0) (2025-11-04)
==================================================================================================
## ✨ Features
+3 -4
View File
@@ -71,7 +71,7 @@ Unless otherwise specified, the following applies to all code:
11. If a variable is not receiving a value on declaration, its type must be defined.
```typescript
let errorMessage: Optional<string>;
let errorMessage: string;
```
12. Objects can use shorthand declarations, including mixing of types.
@@ -150,8 +150,7 @@ Unless otherwise specified, the following applies to all code:
1. When using `any`, a comment explaining why must be present.
27. `import` should be used instead of `require`, as `require` does not have types.
28. Export only what can be reused.
29. Prefer a type like `Optional<X>` (`type Optional<T> = T | null | undefined`) instead
of truly optional parameters.
29. Prefer a type like `X | null` instead of truly optional parameters.
1. A notable exception is when the likelihood of a bug is minimal, such as when a function
takes an argument that is more often not required than required. An example where the
`?` operator is inappropriate is when taking a room ID: typically the caller should
@@ -161,7 +160,7 @@ Unless otherwise specified, the following applies to all code:
```typescript
function doThingWithRoom(
thing: string,
room: Optional<string>, // require the caller to specify
room: string | null, // require the caller to specify
) {
// ...
}
+2
View File
@@ -34,6 +34,8 @@ export default {
"ts-node",
// Used by `@babel/plugin-transform-runtime`
"@babel/runtime",
// Transitive dep of jest
"jsdom",
],
ignoreBinaries: [
// Used when available by reusable workflow `.github/workflows/release-make.yml`
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "39.1.0",
"version": "39.4.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=22.0.0"
@@ -56,10 +56,10 @@
"jwt-decode": "^4.0.0",
"loglevel": "^1.9.2",
"matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.10.0",
"matrix-widget-api": "^1.14.0",
"oidc-client-ts": "^3.0.1",
"p-retry": "7",
"sdp-transform": "^2.14.1",
"sdp-transform": "^3.0.0",
"unhomoglyph": "^1.0.6",
"uuid": "13"
},
@@ -84,12 +84,12 @@
"@stylistic/eslint-plugin": "^5.0.0",
"@types/content-type": "^1.1.5",
"@types/debug": "^4.1.7",
"@types/jest": "^29.0.0",
"@types/jest": "^30.0.0",
"@types/node": "18",
"@types/sdp-transform": "^2.4.5",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"babel-jest": "^29.0.0",
"babel-jest": "^30.0.0",
"babel-plugin-search-and-replace": "^1.1.1",
"debug": "^4.3.4",
"eslint": "8.57.1",
@@ -101,16 +101,16 @@
"eslint-plugin-jsdoc": "^61.0.0",
"eslint-plugin-matrix-org": "^3.0.0",
"eslint-plugin-n": "^14.0.0",
"eslint-plugin-tsdoc": "^0.4.0",
"eslint-plugin-tsdoc": "^0.5.0",
"eslint-plugin-unicorn": "^56.0.0",
"fake-indexeddb": "^5.0.2",
"fetch-mock": "11.1.5",
"fetch-mock-jest": "^1.5.1",
"husky": "^9.0.0",
"jest": "^29.0.0",
"jest-environment-jsdom": "^29.0.0",
"jest": "^30.0.0",
"jest-environment-jsdom": "^30.0.0",
"jest-localstorage-mock": "^2.4.6",
"jest-mock": "^29.0.0",
"jest-mock": "^30.0.0",
"knip": "^5.0.0",
"lint-staged": "^16.0.0",
"matrix-mock-request": "^2.5.0",
+102 -1
View File
@@ -30,7 +30,7 @@ import {
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver.ts";
import { SyncResponder } from "../../test-utils/SyncResponder.ts";
import { mockInitialApiRequests, mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints.ts";
import { getSyncResponse, mkEventCustom, syncPromise } from "../../test-utils/test-utils.ts";
import { getSyncResponse, mkEventCustom, syncPromise, waitFor } from "../../test-utils/test-utils.ts";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder.ts";
import { flushPromises } from "../../test-utils/flushPromises.ts";
import { E2EOTKClaimResponder } from "../../test-utils/E2EOTKClaimResponder.ts";
@@ -80,6 +80,9 @@ describe("History Sharing", () => {
let bobSyncResponder: SyncResponder;
beforeEach(async () => {
// Reset mocks.
fetchMock.reset();
// anything that we don't have a specific matcher for silently returns a 404
fetchMock.catch(404);
fetchMock.config.warnOnFallback = false;
@@ -201,6 +204,104 @@ describe("History Sharing", () => {
expect(event.getContent().body).toEqual("Hi!");
});
test("Room keys are imported correctly if invite is accepted before the bundle arrives", async () => {
// Alice is in an encrypted room
const syncResponse = getSyncResponse([aliceClient.getSafeUserId()], ROOM_ID);
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
// ... and she sends an event
const msgProm = expectSendRoomEvent(ALICE_HOMESERVER_URL, "m.room.encrypted");
await aliceClient.sendEvent(ROOM_ID, EventType.RoomMessage, { msgtype: MsgType.Text, body: "Hello!" });
const sentMessage = await msgProm;
debug(`Alice sent encrypted room event: ${JSON.stringify(sentMessage)}`);
// Now, Alice invites Bob
const uploadProm = new Promise<Uint8Array>((resolve) => {
fetchMock.postOnce(new URL("/_matrix/media/v3/upload", ALICE_HOMESERVER_URL).toString(), (url, request) => {
const body = request.body as Uint8Array;
debug(`Alice uploaded blob of length ${body.length}`);
resolve(body);
return { content_uri: "mxc://alice-server/here" };
});
});
const toDeviceMessageProm = expectSendToDeviceMessage(ALICE_HOMESERVER_URL, "m.room.encrypted");
// POST https://alice-server.com/_matrix/client/v3/rooms/!room%3Aexample.com/invite
fetchMock.postOnce(`${ALICE_HOMESERVER_URL}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {});
await aliceClient.invite(ROOM_ID, bobClient.getSafeUserId(), { shareEncryptedHistory: true });
const uploadedBlob = await uploadProm;
const sentToDeviceRequest = await toDeviceMessageProm;
debug(`Alice sent encrypted to-device events: ${JSON.stringify(sentToDeviceRequest)}`);
const bobToDeviceMessage = sentToDeviceRequest[bobClient.getSafeUserId()][bobClient.deviceId!];
expect(bobToDeviceMessage).toBeDefined();
// Bob receives the room invite, but not the room key bundle
const inviteEvent = mkEventCustom({
type: "m.room.member",
sender: aliceClient.getSafeUserId(),
state_key: bobClient.getSafeUserId(),
content: { membership: KnownMembership.Invite },
});
bobSyncResponder.sendOrQueueSyncResponse({
rooms: { invite: { [ROOM_ID]: { invite_state: { events: [inviteEvent] } } } },
});
await syncPromise(bobClient);
const room = bobClient.getRoom(ROOM_ID);
expect(room).toBeTruthy();
expect(room?.getMyMembership()).toEqual(KnownMembership.Invite);
fetchMock.postOnce(`${BOB_HOMESERVER_URL}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
room_id: ROOM_ID,
});
await bobClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
// Bob receives and attempts to decrypt the megolm message, but should not be able to (yet).
const bobSyncResponse = getSyncResponse([aliceClient.getSafeUserId(), bobClient.getSafeUserId()], ROOM_ID);
bobSyncResponse.rooms.join[ROOM_ID].timeline.events.push(
mkEventCustom({
type: "m.room.encrypted",
sender: aliceClient.getSafeUserId(),
content: sentMessage,
event_id: "$event_id",
}) as any,
);
bobSyncResponder.sendOrQueueSyncResponse(bobSyncResponse);
await syncPromise(bobClient);
const bobRoom = bobClient.getRoom(ROOM_ID);
const event = bobRoom!.getLastLiveEvent()!;
expect(event.getId()).toEqual("$event_id");
await event.getDecryptionPromise();
expect(event.isDecryptionFailure()).toBeTruthy();
// Now the room key bundle message arrives
fetchMock.getOnce(
`begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`,
{ body: uploadedBlob },
{ sendAsJson: false },
);
bobSyncResponder.sendOrQueueSyncResponse({
to_device: {
events: [
{
type: "m.room.encrypted",
sender: aliceClient.getSafeUserId(),
content: bobToDeviceMessage,
},
],
},
});
await syncPromise(bobClient);
// Once the room key bundle finishes downloading, we should be able to decrypt the message.
await waitFor(async () => {
await event.getDecryptionPromise();
expect(event.isDecryptionFailure()).toBeFalsy();
expect(event.getType()).toEqual("m.room.message");
expect(event.getContent().body).toEqual("Hello!");
});
});
afterEach(async () => {
bobClient.stopClient();
aliceClient.stopClient();
+3 -1
View File
@@ -305,7 +305,9 @@ export function encryptMegolmEventRawPlainText(opts: {
},
type: "m.room.encrypted",
unsigned: {},
state_key: opts.plaintext.state_key ? `${opts.plaintext.type}:${opts.plaintext.state_key}` : undefined,
state_key: opts.plaintext.hasOwnProperty("state_key")
? `${opts.plaintext.type}:${opts.plaintext.state_key}`
: undefined,
};
}
@@ -672,7 +672,7 @@ describe("MatrixClient event timelines", function () {
expect(timeline!.getEvents().find((e) => e.getId() === THREAD_ROOT.event_id!)).toBeTruthy();
});
it("should return undefined when event is not in the thread that the given timelineSet is representing", () => {
it("should return null when event is not in the thread that the given timelineSet is representing", () => {
// @ts-ignore
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
@@ -696,12 +696,12 @@ describe("MatrixClient event timelines", function () {
});
return Promise.all([
expect(client.getEventTimeline(timelineSet, EVENTS[0].event_id!)).resolves.toBeUndefined(),
expect(client.getEventTimeline(timelineSet, EVENTS[0].event_id!)).resolves.toBeNull(),
httpBackend.flushAllExpected(),
]);
});
it("should return undefined when event is within a thread but timelineSet is not", () => {
it("should return null when event is within a thread but timelineSet is not", () => {
// @ts-ignore
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
@@ -723,7 +723,7 @@ describe("MatrixClient event timelines", function () {
});
return Promise.all([
expect(client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!)).resolves.toBeUndefined(),
expect(client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!)).resolves.toBeNull(),
httpBackend.flushAllExpected(),
]);
});
+1 -1
View File
@@ -82,7 +82,7 @@ export function getSyncResponse(
state_key: "",
content: {
"algorithm": "m.megolm.v1.aes-sha2",
"io.element.msc3414.encrypt_state_events": encryptStateEvents,
"io.element.msc4362.encrypt_state_events": encryptStateEvents,
},
}),
],
+51 -10
View File
@@ -86,7 +86,9 @@ class MockWidgetApi extends EventEmitter {
? { event_id: `$${Math.random()}` }
: { delay_id: `id-${Math.random()}` },
);
public updateDelayedEvent = jest.fn().mockResolvedValue(undefined);
public cancelScheduledDelayedEvent = jest.fn().mockResolvedValue(undefined);
public restartScheduledDelayedEvent = jest.fn().mockResolvedValue(undefined);
public sendScheduledDelayedEvent = jest.fn().mockResolvedValue(undefined);
public sendToDevice = jest.fn().mockResolvedValue(undefined);
public requestOpenIDConnectToken = jest.fn(async () => {
return testOIDCToken;
@@ -531,17 +533,49 @@ describe("RoomWidgetClient", () => {
).rejects.toThrow();
});
it("updates delayed events", async () => {
it.each([UpdateDelayedEventAction.Cancel, UpdateDelayedEventAction.Restart, UpdateDelayedEventAction.Send])(
"can %s scheduled delayed events (action in parameter)",
async (action: UpdateDelayedEventAction) => {
await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] });
expect(widgetApi.requestCapability).toHaveBeenCalledWith(
MatrixCapabilities.MSC4157UpdateDelayedEvent,
);
await client._unstable_updateDelayedEvent("id", action);
let updateDelayedEvent: (delayId: string) => Promise<unknown>;
switch (action) {
case UpdateDelayedEventAction.Cancel:
updateDelayedEvent = widgetApi.cancelScheduledDelayedEvent;
break;
case UpdateDelayedEventAction.Restart:
updateDelayedEvent = widgetApi.cancelScheduledDelayedEvent;
break;
case UpdateDelayedEventAction.Send:
updateDelayedEvent = widgetApi.sendScheduledDelayedEvent;
break;
}
expect(updateDelayedEvent).toHaveBeenCalledWith("id");
},
);
it("can cancel scheduled delayed events (action in method)", async () => {
await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] });
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent);
for (const action of [
UpdateDelayedEventAction.Cancel,
UpdateDelayedEventAction.Restart,
UpdateDelayedEventAction.Send,
]) {
await client._unstable_updateDelayedEvent("id", action);
expect(widgetApi.updateDelayedEvent).toHaveBeenCalledWith("id", action);
}
await client._unstable_cancelScheduledDelayedEvent("id");
expect(widgetApi.cancelScheduledDelayedEvent).toHaveBeenCalledWith("id");
});
it("can restart scheduled delayed events (action in method)", async () => {
await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] });
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent);
await client._unstable_restartScheduledDelayedEvent("id");
expect(widgetApi.restartScheduledDelayedEvent).toHaveBeenCalledWith("id");
});
it("can send scheduled delayed events (action in method)", async () => {
await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] });
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent);
await client._unstable_sendScheduledDelayedEvent("id");
expect(widgetApi.sendScheduledDelayedEvent).toHaveBeenCalledWith("id");
});
});
@@ -583,6 +617,13 @@ describe("RoomWidgetClient", () => {
"Server does not support",
);
}
for (const updateDelayedEvent of [
client._unstable_cancelScheduledDelayedEvent,
client._unstable_restartScheduledDelayedEvent,
client._unstable_sendScheduledDelayedEvent,
]) {
await expect(updateDelayedEvent.call(client, "id")).rejects.toThrow("Server does not support");
}
});
});
});
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`MatrixHttpApi should return expected object from \`getContentUri\` 1`] = `
{
+2 -2
View File
@@ -375,7 +375,7 @@ describe("FetchHttpApi", () => {
refreshToken,
onlyData: true,
});
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toEqual(
unknownTokenErr,
);
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
@@ -397,7 +397,7 @@ describe("FetchHttpApi", () => {
refreshToken,
onlyData: true,
});
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toEqual(
unknownTokenErr,
);
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
+156 -4
View File
@@ -801,6 +801,10 @@ describe("MatrixClient", function () {
await expect(
client._unstable_updateDelayedEvent("anyDelayId", UpdateDelayedEventAction.Send),
).rejects.toThrow(errorMessage);
await expect(client._unstable_cancelScheduledDelayedEvent("anyDelayId")).rejects.toThrow(errorMessage);
await expect(client._unstable_restartScheduledDelayedEvent("anyDelayId")).rejects.toThrow(errorMessage);
await expect(client._unstable_sendScheduledDelayedEvent("anyDelayId")).rejects.toThrow(errorMessage);
});
it("works with null threadId", async () => {
@@ -1077,21 +1081,169 @@ describe("MatrixClient", function () {
});
});
it("can update delayed events", async () => {
it.each([UpdateDelayedEventAction.Cancel, UpdateDelayedEventAction.Restart, UpdateDelayedEventAction.Send])(
"can %s scheduled delayed events (action in request body)",
async (action: UpdateDelayedEventAction) => {
const delayId = "id";
httpLookups = [
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}`,
data: {
action,
},
},
];
await client._unstable_updateDelayedEvent(delayId, action);
},
);
it.each([UpdateDelayedEventAction.Cancel, UpdateDelayedEventAction.Restart, UpdateDelayedEventAction.Send])(
"can %s scheduled delayed events (action in request body fallback when auth required)",
async (action: UpdateDelayedEventAction) => {
const delayId = "id";
const baseLookup = {
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}`,
};
httpLookups = [
{
...baseLookup,
error: {
httpStatus: 401,
errcode: "M_MISSING_TOKEN",
},
},
{
...baseLookup,
data: {
action,
},
},
];
await client._unstable_updateDelayedEvent(delayId, action);
},
);
it("can cancel scheduled delayed events (action in request path)", async () => {
const delayId = "id";
const action = UpdateDelayedEventAction.Restart;
httpLookups = [
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}/cancel`,
},
];
await client._unstable_cancelScheduledDelayedEvent(delayId);
});
it("can restart scheduled delayed events (action in request path)", async () => {
const delayId = "id";
httpLookups = [
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}/restart`,
},
];
await client._unstable_restartScheduledDelayedEvent(delayId);
});
it("can send scheduled delayed events (action in request path)", async () => {
const delayId = "id";
httpLookups = [
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}/send`,
},
];
await client._unstable_sendScheduledDelayedEvent(delayId);
});
it("can cancel scheduled delayed events (action in request path fallback when unsupported)", async () => {
const delayId = "id";
httpLookups = [
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}/cancel`,
error: {
httpStatus: 400,
errcode: "M_UNRECOGNIZED",
},
},
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}`,
data: {
action,
action: UpdateDelayedEventAction.Cancel,
},
},
];
await client._unstable_updateDelayedEvent(delayId, action);
await client._unstable_cancelScheduledDelayedEvent(delayId);
expect(httpLookups).toHaveLength(0);
});
it("can restart scheduled delayed events (action in request path fallback when unsupported)", async () => {
const delayId = "id";
httpLookups = [
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}/restart`,
error: {
httpStatus: 400,
errcode: "M_UNRECOGNIZED",
},
},
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}`,
data: {
action: UpdateDelayedEventAction.Restart,
},
},
];
await client._unstable_restartScheduledDelayedEvent(delayId);
expect(httpLookups).toHaveLength(0);
});
it("can send scheduled delayed events (action in request path fallback when unsupported)", async () => {
const delayId = "id";
httpLookups = [
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}/send`,
error: {
httpStatus: 400,
errcode: "M_UNRECOGNIZED",
},
},
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}`,
data: {
action: UpdateDelayedEventAction.Send,
},
},
];
await client._unstable_sendScheduledDelayedEvent(delayId);
expect(httpLookups).toHaveLength(0);
});
});
@@ -523,6 +523,9 @@ describe("MatrixRTCSession", () => {
client.sendEvent = sendEventMock;
client._unstable_updateDelayedEvent = jest.fn();
client._unstable_cancelScheduledDelayedEvent = jest.fn();
client._unstable_restartScheduledDelayedEvent = jest.fn();
client._unstable_sendScheduledDelayedEvent = jest.fn();
mockRoom = makeMockRoom([]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
+51 -37
View File
@@ -94,6 +94,9 @@ describe("MembershipManager", () => {
// Provide a default mock that is like the default "non error" server behaviour.
(client._unstable_sendDelayedStateEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue(undefined);
(client._unstable_cancelScheduledDelayedEvent as Mock<any>).mockResolvedValue(undefined);
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockResolvedValue(undefined);
(client._unstable_sendScheduledDelayedEvent as Mock<any>).mockResolvedValue(undefined);
(client._unstable_sendStickyEvent as Mock<any>).mockResolvedValue({ event_id: "id" });
(client._unstable_sendStickyDelayedEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
(client.sendStateEvent as Mock<any>).mockResolvedValue({ event_id: "id" });
@@ -122,7 +125,9 @@ describe("MembershipManager", () => {
it("sends a membership event and schedules delayed leave when joining a call", async () => {
// Spys/Mocks
const updateDelayedEventHandle = createAsyncHandle<void>(client._unstable_updateDelayedEvent as Mock);
const restartScheduledDelayedEventHandle = createAsyncHandle<void>(
client._unstable_restartScheduledDelayedEvent as Mock,
);
// Test
const memberManager = new MembershipManager(undefined, room, client, callSession);
@@ -143,7 +148,7 @@ describe("MembershipManager", () => {
},
"_@alice:example.org_AAAAAAA_m.call",
);
updateDelayedEventHandle.resolve?.();
restartScheduledDelayedEventHandle.resolve?.();
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
room.roomId,
{ delay: 8000 },
@@ -157,13 +162,13 @@ describe("MembershipManager", () => {
it("reschedules delayed leave event if sending state cancels it", async () => {
const memberManager = new MembershipManager(undefined, room, client, callSession);
const waitForSendState = waitForMockCall(client.sendStateEvent);
const waitForUpdateDelaye = waitForMockCallOnce(
client._unstable_updateDelayedEvent,
const waitForRestartScheduledDelayedEvent = waitForMockCallOnce(
client._unstable_restartScheduledDelayedEvent,
Promise.reject(new MatrixError({ errcode: "M_NOT_FOUND" })),
);
memberManager.join([focus], focusActive);
await waitForSendState;
await waitForUpdateDelaye;
await waitForRestartScheduledDelayedEvent;
await jest.advanceTimersByTimeAsync(1);
// Once for the initial event and once because of the errcode: "M_NOT_FOUND"
// Different to "sends a membership event and schedules delayed leave when joining a call" where its only called once (1)
@@ -179,7 +184,7 @@ describe("MembershipManager", () => {
if (useOwnedStateEvents) {
room.getVersion = jest.fn().mockReturnValue("org.matrix.msc3757.default");
}
const updatedDelayedEvent = waitForMockCall(client._unstable_updateDelayedEvent);
const restartScheduledDelayedEvent = waitForMockCall(client._unstable_restartScheduledDelayedEvent);
const sentDelayedState = waitForMockCall(
client._unstable_sendDelayedStateEvent,
Promise.resolve({
@@ -265,13 +270,13 @@ describe("MembershipManager", () => {
await sentDelayedState;
// should have prepared the heartbeat to keep delaying the leave event while still connected
await updatedDelayedEvent;
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
await restartScheduledDelayedEvent;
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1);
// ensures that we reach the code that schedules the timeout for the next delay update before we advance the timers.
await jest.advanceTimersByTimeAsync(5000);
// should update delayed disconnect
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(2);
}
it("sends a membership event after rate limits during delayed event setup when joining a call", async () => {
@@ -343,7 +348,7 @@ describe("MembershipManager", () => {
// (onRTCSessionMemberUpdate)
// - Only then do we resolve the sending of the delayed event.
// - We test that the manager acknowledges the leave and sends a new membership state event.
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValueOnce(
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockRejectedValueOnce(
new MatrixError({ errcode: "M_NOT_FOUND" }),
);
@@ -404,17 +409,17 @@ describe("MembershipManager", () => {
manager.join([focus]);
await jest.advanceTimersByTimeAsync(1);
await manager.leave();
expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", "send");
expect(client._unstable_sendScheduledDelayedEvent).toHaveBeenLastCalledWith("id");
expect(client.sendStateEvent).toHaveBeenCalled();
});
it("send leave event when leave is called and resolving delayed leave fails", async () => {
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus]);
await jest.advanceTimersByTimeAsync(1);
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
(client._unstable_sendScheduledDelayedEvent as Mock<any>).mockRejectedValue("unknown");
await manager.leave();
// We send a normal leave event since we failed using updateDelayedEvent with the "send" action.
// We send a normal leave event since we failed using sendScheduledDelayedEvent.
expect(client.sendStateEvent).toHaveBeenLastCalledWith(
room.roomId,
"org.matrix.msc3401.call.member",
@@ -438,6 +443,9 @@ describe("MembershipManager", () => {
expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
expect(client._unstable_cancelScheduledDelayedEvent).not.toHaveBeenCalled();
expect(client._unstable_restartScheduledDelayedEvent).not.toHaveBeenCalled();
expect(client._unstable_sendScheduledDelayedEvent).not.toHaveBeenCalled();
});
it("does nothing if own membership still present", async () => {
const manager = new MembershipManager({}, room, client, callSession);
@@ -447,6 +455,9 @@ describe("MembershipManager", () => {
// reset all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
(client.sendStateEvent as Mock).mockClear();
(client._unstable_updateDelayedEvent as Mock).mockClear();
(client._unstable_cancelScheduledDelayedEvent as Mock).mockClear();
(client._unstable_restartScheduledDelayedEvent as Mock).mockClear();
(client._unstable_sendScheduledDelayedEvent as Mock).mockClear();
(client._unstable_sendDelayedStateEvent as Mock).mockClear();
await manager.onRTCSessionMemberUpdate([
@@ -462,6 +473,9 @@ describe("MembershipManager", () => {
expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
expect(client._unstable_cancelScheduledDelayedEvent).not.toHaveBeenCalled();
expect(client._unstable_restartScheduledDelayedEvent).not.toHaveBeenCalled();
expect(client._unstable_sendScheduledDelayedEvent).not.toHaveBeenCalled();
});
it("recreates membership if it is missing", async () => {
const manager = new MembershipManager({}, room, client, callSession);
@@ -469,7 +483,7 @@ describe("MembershipManager", () => {
await jest.advanceTimersByTimeAsync(1);
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
(client.sendStateEvent as Mock).mockClear();
(client._unstable_updateDelayedEvent as Mock).mockClear();
(client._unstable_restartScheduledDelayedEvent as Mock).mockClear();
(client._unstable_sendDelayedStateEvent as Mock).mockClear();
// Our own membership is removed:
@@ -478,7 +492,7 @@ describe("MembershipManager", () => {
expect(client.sendStateEvent).toHaveBeenCalled();
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled();
expect(client._unstable_updateDelayedEvent).toHaveBeenCalled();
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalled();
});
it("updates the UpdateExpiry entry in the action scheduler", async () => {
@@ -487,10 +501,10 @@ describe("MembershipManager", () => {
await jest.advanceTimersByTimeAsync(1);
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
(client.sendStateEvent as Mock).mockClear();
(client._unstable_updateDelayedEvent as Mock).mockClear();
(client._unstable_restartScheduledDelayedEvent as Mock).mockClear();
(client._unstable_sendDelayedStateEvent as Mock).mockClear();
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValueOnce(
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockRejectedValueOnce(
new MatrixError({ errcode: "M_NOT_FOUND" }),
);
@@ -503,7 +517,7 @@ describe("MembershipManager", () => {
expect(client.sendStateEvent).toHaveBeenCalled();
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled();
expect(client._unstable_updateDelayedEvent).toHaveBeenCalled();
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalled();
expect(manager.status).toBe(Status.Connected);
});
});
@@ -523,17 +537,17 @@ describe("MembershipManager", () => {
// The first call is from checking id the server deleted the delayed event
// so it does not need a `advanceTimersByTime`
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1);
// TODO: Check that update delayed event is called with the correct HTTP request timeout
// expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 });
// expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 });
for (let i = 2; i <= 12; i++) {
// flush promises before advancing the timers to make sure schedulers are setup
await jest.advanceTimersByTimeAsync(10_000);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(i);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(i);
// TODO: Check that update delayed event is called with the correct HTTP request timeout
// expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 });
// expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 });
}
});
@@ -681,7 +695,7 @@ describe("MembershipManager", () => {
});
describe("retries sending update delayed leave event restart", () => {
it("resends the initial check delayed update event", async () => {
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue(
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockRejectedValue(
new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED" },
429,
@@ -695,17 +709,17 @@ describe("MembershipManager", () => {
// Hit rate limit
await jest.advanceTimersByTimeAsync(1);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1);
// Hit second rate limit.
await jest.advanceTimersByTimeAsync(1000);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(2);
// Setup resolve
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue(undefined);
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockResolvedValue(undefined);
await jest.advanceTimersByTimeAsync(1000);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(3);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
});
});
@@ -734,7 +748,7 @@ describe("MembershipManager", () => {
// because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
it("throws, when reaching maximum number of retries", async () => {
const delayEventRestartError = jest.fn();
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue(
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockRejectedValue(
new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED" },
429,
@@ -808,11 +822,11 @@ describe("MembershipManager", () => {
manager.join([focus], focusActive);
try {
// Let the scheduler run one iteration so that we can send the join state event
await waitForMockCall(client._unstable_updateDelayedEvent);
await waitForMockCall(client._unstable_restartScheduledDelayedEvent);
// We never resolve the delayed event so that we can test the probablyLeft event.
// This simulates the case where the server does not respond to the delayed event.
client._unstable_updateDelayedEvent = jest.fn(() => stuckPromise);
client._unstable_restartScheduledDelayedEvent = jest.fn(() => stuckPromise);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
expect(manager.status).toBe(Status.Connected);
expect(probablyLeftEmit).not.toHaveBeenCalledWith(true);
@@ -822,18 +836,18 @@ describe("MembershipManager", () => {
await jest.advanceTimersByTimeAsync(5000);
// No emission after 5s
expect(probablyLeftEmit).not.toHaveBeenCalledWith(true);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(4999);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(3);
expect(probablyLeftEmit).not.toHaveBeenCalledWith(true);
// Reset mocks before we setup the next delayed event restart by advancing the timers 1 more ms.
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue({});
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockResolvedValue({});
// Emit after 10s
await jest.advanceTimersByTimeAsync(1);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(4);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(4);
expect(probablyLeftEmit).toHaveBeenCalledWith(true);
// Mock a sync which does not include our own membership
@@ -898,8 +912,8 @@ describe("MembershipManager", () => {
describe("join()", () => {
describe("sends an rtc membership event", () => {
it("sends a membership event and schedules delayed leave when joining a call", async () => {
const updateDelayedEventHandle = createAsyncHandle<void>(
client._unstable_updateDelayedEvent as Mock,
const restartScheduledDelayedEventHandle = createAsyncHandle<void>(
client._unstable_restartScheduledDelayedEvent as Mock,
);
const memberManager = new StickyEventMembershipManager(undefined, room, client, callSession);
@@ -925,7 +939,7 @@ describe("MembershipManager", () => {
msc4354_sticky_key: "_@alice:example.org_AAAAAAA_m.call",
},
);
updateDelayedEventHandle.resolve?.();
restartScheduledDelayedEventHandle.resolve?.();
// Ensure we have sent the delayed disconnect event.
expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledWith(
+6
View File
@@ -52,6 +52,9 @@ export type MockClient = Pick<
| "sendStateEvent"
| "_unstable_sendDelayedStateEvent"
| "_unstable_updateDelayedEvent"
| "_unstable_cancelScheduledDelayedEvent"
| "_unstable_restartScheduledDelayedEvent"
| "_unstable_sendScheduledDelayedEvent"
| "_unstable_sendStickyEvent"
| "_unstable_sendStickyDelayedEvent"
| "cancelPendingEvent"
@@ -67,6 +70,9 @@ export function makeMockClient(userId: string, deviceId: string): MockClient {
sendStateEvent: jest.fn(),
cancelPendingEvent: jest.fn(),
_unstable_updateDelayedEvent: jest.fn(),
_unstable_cancelScheduledDelayedEvent: jest.fn(),
_unstable_restartScheduledDelayedEvent: jest.fn(),
_unstable_sendScheduledDelayedEvent: jest.fn(),
_unstable_sendDelayedStateEvent: jest.fn(),
_unstable_sendStickyEvent: jest.fn(),
_unstable_sendStickyDelayedEvent: jest.fn(),
+4 -2
View File
@@ -127,15 +127,17 @@ describe("Poll", () => {
it("waits for existing relations request to finish when getting responses", async () => {
const poll = new Poll(basePollStartEvent, mockClient, room);
// @ts-expect-error TS2769
const spy = jest.spyOn(poll, "fetchResponses");
const firstResponsePromise = poll.getResponses();
const secondResponsePromise = poll.getResponses();
await firstResponsePromise;
expect(firstResponsePromise).toEqual(secondResponsePromise);
await secondResponsePromise;
expect(spy).toHaveBeenCalledTimes(1);
expect(mockClient.relations).toHaveBeenCalledTimes(1);
});
it("filters relations for relevent response events", async () => {
it("filters relations for relevant response events", async () => {
const replyEvent = makeRelatedEvent({ type: "m.room.message" });
const stableResponseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.stable! });
const unstableResponseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.unstable });
+68 -1
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Direction, type MatrixClient, MatrixEvent, Room } from "../../../src";
import { Direction, EventType, type MatrixClient, MatrixEvent, Room } from "../../../src";
import type { MockedObject } from "jest-mock";
const CREATOR_USER_ID = "@creator:example.org";
@@ -185,4 +185,71 @@ describe("Room", () => {
expectRedacted(messageEvents, room, false);
});
});
it("should ignore invalid m.room.name events", async () => {
const mockClient = createMockClient();
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
const invalidNameEvent = new MatrixEvent({
type: EventType.RoomName,
content: {
name: { invalid: 123 },
},
state_key: "",
event_id: "$123",
room_id: room.roomId,
sender: CREATOR_USER_ID,
});
// Set up the room
room.currentState.setStateEvents([invalidNameEvent]);
room.recalculate();
expect(room.name).toEqual("Empty room");
});
describe("getAltAliases()", () => {
it("should ignore invalid events", async () => {
const mockClient = createMockClient();
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
const invalidAliasEvent = new MatrixEvent({
type: EventType.RoomCanonicalAlias,
content: {
alt_aliases: [123, "#foo:bar"],
},
state_key: "",
event_id: "$123",
room_id: room.roomId,
sender: CREATOR_USER_ID,
});
// Set up the room
room.currentState.setStateEvents([invalidAliasEvent]);
room.recalculate();
expect(room.getAltAliases()).toEqual(["#foo:bar"]);
});
});
describe("calculateRoomName()", () => {
it("should ignore empty m.room.name 'name' field", async () => {
const mockClient = createMockClient();
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
const event = new MatrixEvent({
type: EventType.RoomName,
content: {
name: "",
},
state_key: "",
event_id: "$123",
room_id: room.roomId,
sender: CREATOR_USER_ID,
});
// Set up the room
room.currentState.setStateEvents([event]);
room.recalculate();
expect(room.name).not.toEqual("");
});
});
});
+4 -4
View File
@@ -19,7 +19,7 @@ limitations under the License.
*/
import { mocked } from "jest-mock";
import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START, type Optional, PollStartEvent } from "matrix-events-sdk";
import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START, PollStartEvent } from "matrix-events-sdk";
import * as utils from "../test-utils/test-utils";
import { emitPromise, type IMessageOpts } from "../test-utils/test-utils";
@@ -197,8 +197,8 @@ describe("Room", function () {
const addRoomThreads = (
room: Room,
thread1EventTs: Optional<number>,
thread2EventTs: Optional<number>,
thread1EventTs?: number,
thread2EventTs?: number,
): { thread1?: Thread; thread2?: Thread } => {
const result: { thread1?: Thread; thread2?: Thread } = {};
@@ -4159,7 +4159,7 @@ describe("Room", function () {
});
it("when there is only one thread, it should return this one", () => {
const { thread1 } = addRoomThreads(room, 23, null);
const { thread1 } = addRoomThreads(room, 23);
expect(room.getLastThread()).toBe(thread1);
});
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Mocked, type SpyInstance } from "jest-mock";
import { type Mocked } from "jest-mock";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { type OlmMachine } from "@matrix-org/matrix-sdk-crypto-wasm";
import fetchMock from "fetch-mock-jest";
@@ -211,14 +211,14 @@ describe("PerSessionKeyBackupDownloader", () => {
fetchMock.get(`path:/_matrix/client/v3/room_keys/keys/!roomA/sessionA1`, mockCipherKey);
// @ts-ignore access to private function
const spy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const spy: jest.SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const expectImported = expectSessionImported("!roomA", "sessionA1");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
await jest.runAllTimersAsync();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveLastReturnedWith(Promise.resolve({ ok: false, error: "MISSING_DECRYPTION_KEY" }));
await expect(spy.mock.results[0].value).rejects.toThrow("MISSING_DECRYPTION_KEY");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA1");
await jest.runAllTimersAsync();
@@ -237,7 +237,7 @@ describe("PerSessionKeyBackupDownloader", () => {
});
// @ts-ignore access to private function
const spy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const spy: jest.SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
await jest.runAllTimersAsync();
@@ -297,7 +297,7 @@ describe("PerSessionKeyBackupDownloader", () => {
});
describe("Given no usable backup available", () => {
let getConfigSpy: SpyInstance;
let getConfigSpy: jest.SpyInstance;
beforeEach(async () => {
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(null);
@@ -318,7 +318,7 @@ describe("PerSessionKeyBackupDownloader", () => {
await jest.runAllTimersAsync();
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
await expect(getConfigSpy.mock.results[0].value).resolves.toEqual(null);
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
@@ -336,7 +336,7 @@ describe("PerSessionKeyBackupDownloader", () => {
await jest.runAllTimersAsync();
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
await expect(getConfigSpy.mock.results[0].value).resolves.toEqual(null);
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
@@ -355,7 +355,7 @@ describe("PerSessionKeyBackupDownloader", () => {
await jest.runAllTimersAsync();
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
await expect(getConfigSpy.mock.results[0].value).resolves.toEqual(null);
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
@@ -377,7 +377,7 @@ describe("PerSessionKeyBackupDownloader", () => {
await jest.runAllTimersAsync();
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
await expect(getConfigSpy.mock.results[0].value).resolves.toEqual(null);
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
@@ -399,7 +399,7 @@ describe("PerSessionKeyBackupDownloader", () => {
await jest.runAllTimersAsync();
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
await expect(getConfigSpy.mock.results[0].value).resolves.toEqual(null);
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
@@ -488,7 +488,7 @@ describe("PerSessionKeyBackupDownloader", () => {
const originalImplementation = downloader.queryKeyBackup.bind(downloader);
// @ts-ignore access to private function
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const keyQuerySpy: jest.SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const rateDeferred = Promise.withResolvers<void>();
keyQuerySpy.mockImplementation(
@@ -542,7 +542,7 @@ describe("PerSessionKeyBackupDownloader", () => {
const originalImplementation = downloader.queryKeyBackup.bind(downloader);
// @ts-ignore
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const keyQuerySpy: jest.SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const errorDeferred = Promise.withResolvers<void>();
keyQuerySpy.mockImplementation(
@@ -606,7 +606,7 @@ describe("PerSessionKeyBackupDownloader", () => {
});
// @ts-ignore access to private function
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const keyQuerySpy: jest.SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA1");
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`RustCrypto importing and exporting room keys should import and export keys 1`] = `
{
+2 -2
View File
@@ -98,7 +98,7 @@ function createLinkedTimelines(): [EventTimeline, EventTimeline] {
describe("TimelineIndex", function () {
beforeEach(() => {
jest.clearAllMocks();
mockClient.getEventTimeline.mockResolvedValue(undefined);
mockClient.getEventTimeline.mockResolvedValue(null);
});
describe("minIndex", function () {
@@ -193,7 +193,7 @@ describe("TimelineWindow", function () {
beforeEach(() => {
jest.clearAllMocks();
mockClient.getEventTimeline.mockResolvedValue(undefined);
mockClient.getEventTimeline.mockResolvedValue(null);
mockClient.paginateEventTimeline.mockResolvedValue(false);
});
+5 -3
View File
@@ -187,12 +187,14 @@ describe("Group Call", function () {
});
it("does not start initializing local call feed twice", () => {
const promise1 = groupCall.initLocalCallFeed();
// @ts-expect-error TS2769
const spy = jest.spyOn(groupCall, "initLocalCallFeedInternal");
groupCall.initLocalCallFeed();
// @ts-ignore Mock
groupCall.state = GroupCallState.LocalCallFeedUninitialized;
const promise2 = groupCall.initLocalCallFeed();
groupCall.initLocalCallFeed();
expect(promise1).toEqual(promise2);
expect(spy).toHaveBeenCalledTimes(1);
});
it("sets state to local call feed uninitialized when getUserMedia() fails", async () => {
+80 -6
View File
@@ -61,10 +61,10 @@ describe("Media Handler", function () {
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(
expect.objectContaining({
audio: expect.objectContaining({
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
deviceId: { exact: FAKE_AUDIO_INPUT_ID },
}),
video: expect.objectContaining({
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
deviceId: { exact: FAKE_VIDEO_INPUT_ID },
}),
}),
);
@@ -77,7 +77,7 @@ describe("Media Handler", function () {
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(
expect.objectContaining({
audio: expect.objectContaining({
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
deviceId: { exact: FAKE_AUDIO_INPUT_ID },
}),
}),
);
@@ -109,7 +109,7 @@ describe("Media Handler", function () {
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(
expect.objectContaining({
video: expect.objectContaining({
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
deviceId: { exact: FAKE_VIDEO_INPUT_ID },
}),
}),
);
@@ -122,10 +122,10 @@ describe("Media Handler", function () {
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(
expect.objectContaining({
audio: expect.objectContaining({
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
deviceId: { exact: FAKE_AUDIO_INPUT_ID },
}),
video: expect.objectContaining({
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
deviceId: { exact: FAKE_VIDEO_INPUT_ID },
}),
}),
);
@@ -331,6 +331,80 @@ describe("Media Handler", function () {
expect(stream.getVideoTracks().length).toEqual(0);
});
it("falls back to ideal deviceId when exact deviceId fails", async () => {
// First call with exact should fail
mockMediaDevices.getUserMedia
.mockRejectedValueOnce(new Error("OverconstrainedError"))
.mockImplementation((constraints: MediaStreamConstraints) => {
const stream = new MockMediaStream("local_stream");
if (constraints.audio) {
const track = new MockMediaStreamTrack("audio_track", "audio");
track.settings = { deviceId: FAKE_AUDIO_INPUT_ID };
stream.addTrack(track);
}
return Promise.resolve(stream.typed());
});
const stream = await mediaHandler.getUserMediaStream(true, false);
// Should have been called twice: once with exact, once with ideal
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledTimes(2);
expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
audio: expect.objectContaining({
deviceId: { exact: FAKE_AUDIO_INPUT_ID },
}),
}),
);
expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
audio: expect.objectContaining({
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
}),
}),
);
expect(stream).toBeTruthy();
});
it("falls back to ideal deviceId for video when exact fails", async () => {
// First call with exact should fail
mockMediaDevices.getUserMedia
.mockRejectedValueOnce(new Error("OverconstrainedError"))
.mockImplementation((constraints: MediaStreamConstraints) => {
const stream = new MockMediaStream("local_stream");
if (constraints.video) {
const track = new MockMediaStreamTrack("video_track", "video");
track.settings = { deviceId: FAKE_VIDEO_INPUT_ID };
stream.addTrack(track);
}
return Promise.resolve(stream.typed());
});
const stream = await mediaHandler.getUserMediaStream(false, true);
// Should have been called twice: once with exact, once with ideal
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledTimes(2);
expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
video: expect.objectContaining({
deviceId: { exact: FAKE_VIDEO_INPUT_ID },
}),
}),
);
expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
video: expect.objectContaining({
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
}),
}),
);
expect(stream).toBeTruthy();
});
});
describe("getScreensharingStream", () => {
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`CallFeedStatsReporter should builds CallFeedReport 1`] = `
{
+2 -5
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type EitherAnd, NamespacedValue, type Optional, UnstableValue } from "matrix-events-sdk";
import { type EitherAnd, NamespacedValue, UnstableValue } from "matrix-events-sdk";
import { isProvided } from "../extensible_events_v1/utilities.ts";
@@ -125,10 +125,7 @@ export type ExtensibleEventType = NamespacedValue<string, string> | string;
* @param expected - The expected event type.
* @returns True if the given type matches the expected type.
*/
export function isEventTypeSame(
given: Optional<ExtensibleEventType>,
expected: Optional<ExtensibleEventType>,
): boolean {
export function isEventTypeSame(given: ExtensibleEventType | null, expected: ExtensibleEventType | null): boolean {
if (typeof given === "string") {
if (typeof expected === "string") {
return expected === given;
+6 -6
View File
@@ -298,12 +298,12 @@ export interface IRelationsResponse {
}
export interface IContextResponse {
end: string;
start: string;
state: IEventWithRoomId[];
events_before: IEventWithRoomId[];
events_after: IEventWithRoomId[];
event: IEventWithRoomId;
end?: string;
start?: string;
state?: IEventWithRoomId[];
events_before?: IEventWithRoomId[];
events_after?: IEventWithRoomId[];
event?: IEventWithRoomId;
}
export interface IEventsResponse {
+1 -1
View File
@@ -106,7 +106,7 @@ export interface RoomPinnedEventsEventContent {
export interface RoomEncryptionEventContent {
"algorithm": "m.megolm.v1.aes-sha2";
"io.element.msc3414.encrypt_state_events"?: boolean;
"io.element.msc4362.encrypt_state_events"?: boolean;
"rotation_period_ms"?: number;
"rotation_period_msgs"?: number;
}
+1 -3
View File
@@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Optional } from "matrix-events-sdk";
/**
* Represents a simple Matrix namespaced value. This will assume that if a stable prefix
* is provided that the stable prefix should be used when representing the identifier.
@@ -62,7 +60,7 @@ export class NamespacedValue<S extends string, U extends string> {
// this desperately wants https://github.com/microsoft/TypeScript/pull/26349 at the top level of the class
// so we can instantiate `NamespacedValue<string, _, _>` as a default type for that namespace.
public findIn<T>(obj: any): Optional<T> {
public findIn<T>(obj: any): T | undefined {
let val: T | undefined = undefined;
if (this.name) {
val = obj?.[this.name];
+175 -47
View File
@@ -18,8 +18,6 @@ limitations under the License.
* This is an internal module. See {@link MatrixClient} for the public class.
*/
import { type Optional } from "matrix-events-sdk";
import type { IDeviceKeys, IOneTimeKey } from "./@types/crypto.ts";
import { type ISyncStateData, type SetPresence, SyncApi, type SyncApiOptions, SyncState } from "./sync.ts";
import {
@@ -106,6 +104,7 @@ import { RoomMemberEvent, type RoomMemberEventHandlerMap } from "./models/room-m
import { type IPowerLevelsContent, type RoomStateEvent, type RoomStateEventHandlerMap } from "./models/room-state.ts";
import {
isSendDelayedEventRequestOpts,
UpdateDelayedEventAction,
type DelayedEventInfo,
type IAddThreePidOnlyBody,
type IBindThreePidBody,
@@ -130,7 +129,6 @@ import {
type KnockRoomOpts,
type SendDelayedEventRequestOpts,
type SendDelayedEventResponse,
type UpdateDelayedEventAction,
} from "./@types/requests.ts";
import {
type AccountDataEvents,
@@ -2408,7 +2406,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const roomId = res.room_id;
if (opts.acceptSharedHistory && inviter && this.cryptoBackend) {
await this.cryptoBackend.maybeAcceptKeyBundle(roomId, inviter);
// Try to accept the room key bundle specified in a `m.room_key_bundle` to-device message we (might have) already received.
const bundleDownloaded = await this.cryptoBackend.maybeAcceptKeyBundle(roomId, inviter);
// If this fails, i.e. we haven't received this message yet, we need to wait until the to-device message arrives.
if (!bundleDownloaded) {
this.cryptoBackend.markRoomAsPendingKeyBundle(roomId, inviter);
}
}
// In case we were originally given an alias, check the room cache again
@@ -3443,7 +3446,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
delayOpts: SendDelayedEventRequestOpts,
threadId: string | null,
eventType: K,
content: TimelineEvents[K] & { msc4354_sticky_key: string },
content: TimelineEvents[K] & { msc4354_sticky_key?: string },
txnId?: string,
): Promise<SendDelayedEventResponse> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
@@ -3516,7 +3519,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
stickDuration: number,
threadId: string | null,
eventType: K,
content: TimelineEvents[K] & { msc4354_sticky_key: string },
content: TimelineEvents[K] & { msc4354_sticky_key?: string },
txnId?: string,
): Promise<ISendEventResponse> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS))) {
@@ -3570,8 +3573,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* Note: This endpoint is unstable, and can throw an `Error`.
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details.
*
* @deprecated Instead use one of:
* - {@link _unstable_cancelScheduledDelayedEvent}
* - {@link _unstable_restartScheduledDelayedEvent}
* - {@link _unstable_sendScheduledDelayedEvent}
*/
// eslint-disable-next-line
// eslint-disable-next-line @typescript-eslint/naming-convention
public async _unstable_updateDelayedEvent(
delayId: string,
action: UpdateDelayedEventAction,
@@ -3583,17 +3591,123 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
"updateDelayedEvent",
);
}
return await this.updateScheduledDelayedEventWithActionInBody(delayId, action, requestOptions);
}
/**
* Cancel the scheduled delivery of the delayed event matching the provided delayId.
*
* Note: This endpoint is unstable, and can throw an `Error`.
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details.
*
* @throws A M_NOT_FOUND error if no matching delayed event could be found.
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
public async _unstable_cancelScheduledDelayedEvent(
delayId: string,
requestOptions: IRequestOpts = {},
): Promise<EmptyObject> {
return await this.updateScheduledDelayedEvent(delayId, UpdateDelayedEventAction.Cancel, requestOptions);
}
/**
* Restart the scheduled delivery of the delayed event matching the given delayId.
*
* Note: This endpoint is unstable, and can throw an `Error`.
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details.
*
* @throws A M_NOT_FOUND error if no matching delayed event could be found.
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
public async _unstable_restartScheduledDelayedEvent(
delayId: string,
requestOptions: IRequestOpts = {},
): Promise<EmptyObject> {
return await this.updateScheduledDelayedEvent(delayId, UpdateDelayedEventAction.Restart, requestOptions);
}
/**
* Immediately send the delayed event matching the given delayId,
* instead of waiting for its scheduled delivery.
*
* Note: This endpoint is unstable, and can throw an `Error`.
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details.
*
* @throws A M_NOT_FOUND error if no matching delayed event could be found.
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
public async _unstable_sendScheduledDelayedEvent(
delayId: string,
requestOptions: IRequestOpts = {},
): Promise<EmptyObject> {
return await this.updateScheduledDelayedEvent(delayId, UpdateDelayedEventAction.Send, requestOptions);
}
private async updateScheduledDelayedEvent(
delayId: string,
action: UpdateDelayedEventAction,
requestOptions: IRequestOpts = {},
): Promise<EmptyObject> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
`${action}ScheduledDelayedEvent`,
);
}
try {
const path = utils.encodeUri("/delayed_events/$delayId/$action", {
$delayId: delayId,
$action: action,
});
return await this.http.request(Method.Post, path, undefined, undefined, {
...requestOptions,
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
});
} catch (e) {
if (e instanceof MatrixError && e.errcode === "M_UNRECOGNIZED") {
// For backwards compatibility with an older version of this endpoint
// which put the update action in the request body instead of the path
return await this.updateScheduledDelayedEventWithActionInBody(delayId, action, requestOptions);
} else {
throw e;
}
}
}
/**
* @deprecated Present for backwards compatibility with an older version of MSC4140
* which had a single, authenticated endpoint for updating a delayed event, instead
* of one unauthenticated endpoint per update action.
*/
private async updateScheduledDelayedEventWithActionInBody(
delayId: string,
action: UpdateDelayedEventAction,
requestOptions: IRequestOpts = {},
): Promise<EmptyObject> {
const path = utils.encodeUri("/delayed_events/$delayId", {
$delayId: delayId,
});
const data = {
action,
};
return await this.http.authedRequest(Method.Post, path, undefined, data, {
...requestOptions,
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
});
try {
return await this.http.request(Method.Post, path, undefined, data, {
...requestOptions,
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
});
} catch (e) {
if (e instanceof MatrixError && e.errcode === "M_MISSING_TOKEN") {
// For backwards compatibility with an older version of this endpoint
// which required authentication
return await this.http.authedRequest(Method.Post, path, undefined, data, {
...requestOptions,
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
});
} else {
throw e;
}
}
}
/**
@@ -4336,6 +4450,46 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return eventMapperFor(this, options || {});
}
/**
* Calls the `/context` API for the given room ID & event ID.
* Returns the response, with `event` asserted and all optional arrays defaulted to an empty array.
* @param roomId - the room ID to request a context for
* @param eventId - the event ID to request a context for
* @throws if `event` in the response is missing
* @private
*/
private async getEventContext(
roomId: string,
eventId: string,
): Promise<IContextResponse & Omit<Required<IContextResponse>, "start" | "end">> {
const path = utils.encodeUri("/rooms/$roomId/context/$eventId", {
$roomId: roomId,
$eventId: eventId,
});
const params: Record<string, string | string[]> = {
limit: "0",
};
if (this.clientOpts?.lazyLoadMembers) {
params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER);
}
// TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors.
const res = await this.http.authedRequest<IContextResponse>(Method.Get, path, params);
if (res.event) {
return {
start: res.start,
end: res.end,
event: res.event,
events_after: res.events_after ?? [],
events_before: res.events_before ?? [],
state: res.state ?? [],
};
}
throw new Error("'event' not in '/context' result - homeserver too old?");
}
/**
* Get an EventTimeline for the given event
*
@@ -4350,7 +4504,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Promise which resolves:
* {@link EventTimeline} including the given event
*/
public async getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise<Optional<EventTimeline>> {
public async getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise<EventTimeline | null> {
// don't allow any timeline support unless it's been enabled.
if (!this.timelineSupport) {
throw new Error(
@@ -4368,24 +4522,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
if (timelineSet.thread && this.supportsThreads()) {
return this.getThreadTimeline(timelineSet, eventId);
return (await this.getThreadTimeline(timelineSet, eventId)) ?? null;
}
const path = utils.encodeUri("/rooms/$roomId/context/$eventId", {
$roomId: timelineSet.room.roomId,
$eventId: eventId,
});
let params: Record<string, string | string[]> | undefined = undefined;
if (this.clientOpts?.lazyLoadMembers) {
params = { filter: JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER) };
}
// TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors.
const res = await this.http.authedRequest<IContextResponse>(Method.Get, path, params);
if (!res.event) {
throw new Error("'event' not in '/context' result - homeserver too old?");
}
const res = await this.getEventContext(timelineSet.room.roomId, eventId);
// by the time the request completes, the event might have ended up in the timeline.
if (timelineSet.getTimelineForEvent(eventId)) {
@@ -4396,7 +4536,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const event = mapper(res.event);
if (event.isRelation(THREAD_RELATION_TYPE.name)) {
this.logger.warn("Tried loading a regular timeline at the position of a thread event");
return undefined;
return null;
}
const events = [
// Order events from most recent to oldest (reverse-chronological).
@@ -4414,7 +4554,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
} else {
timeline = timelineSet.addTimeline();
timeline.initialiseState(res.state.map(mapper));
timeline.getState(EventTimeline.FORWARDS)!.paginationToken = res.end;
timeline.getState(EventTimeline.FORWARDS)!.paginationToken = res.end ?? null;
}
const [timelineEvents, threadedEvents, unknownRelations] = timelineSet.room.partitionThreadedEvents(events);
@@ -4447,20 +4587,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
throw new Error("could not get thread timeline: not a thread timeline");
}
const path = utils.encodeUri("/rooms/$roomId/context/$eventId", {
$roomId: timelineSet.room.roomId,
$eventId: eventId,
});
const res = await this.getEventContext(timelineSet.room.roomId, eventId);
const params: Record<string, string | string[]> = {
limit: "0",
};
if (this.clientOpts?.lazyLoadMembers) {
params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER);
}
// TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors.
const res = await this.http.authedRequest<IContextResponse>(Method.Get, path, params);
const mapper = this.getEventMapper();
const event = mapper(res.event);
@@ -4540,7 +4668,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
{ dir: Direction.Backward, from: res.start, recurse: recurse || undefined },
);
const eventsNewer: IEvent[] = [];
let nextBatch: Optional<string> = res.end;
let nextBatch = res.end;
while (nextBatch) {
const resNewer: IRelationsResponse = await this.fetchRelations(
timelineSet.room.roomId,
@@ -4549,7 +4677,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
null,
{ dir: Direction.Forward, from: nextBatch, recurse: recurse || undefined },
);
nextBatch = resNewer.next_batch ?? null;
nextBatch = resNewer.next_batch;
eventsNewer.push(...resNewer.chunk);
}
const events = [
@@ -4593,7 +4721,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Promise which resolves:
* {@link EventTimeline} timeline with the latest events in the room
*/
public async getLatestTimeline(timelineSet: EventTimelineSet): Promise<Optional<EventTimeline>> {
public async getLatestTimeline(timelineSet: EventTimelineSet): Promise<EventTimeline | null> {
// don't allow any timeline support unless it's been enabled.
if (!this.timelineSupport) {
throw new Error(
@@ -6805,7 +6933,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return;
}
// Check if the event is excluded under MSC3414
// Check if the event is excluded under MSC4362
if (
[
"m.room.create",
+13 -1
View File
@@ -90,8 +90,20 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
*
* @param inviter - The user who invited us to the room and is expected to have
* sent the room key bundle.
*
* @returns `true` if the key bundle was successfuly downloaded and imported.
*/
maybeAcceptKeyBundle(roomId: string, inviter: string): Promise<void>;
maybeAcceptKeyBundle(roomId: string, inviter: string): Promise<boolean>;
/**
* Mark a room as pending a key bundle under MSC4268. The backend will listen for room key bundle messages, and if
* it sees one matching the room specified, it will automatically import it as long as the message author's ID matches
* the inviter's ID.
*
* @param roomId - The room we were invited to, for which we did not receive a key bundle before accepting the invite.
* @param inviterId - The user who invited us to the room and is expected to send the room key bundle.
*/
markRoomAsPendingKeyBundle(roomId: string, inviterId: string): void;
}
/** The methods which crypto implementations should expose to the Sync api
+67 -3
View File
@@ -37,7 +37,7 @@ import {
type ISendEventResponse,
type SendDelayedEventRequestOpts,
type SendDelayedEventResponse,
type UpdateDelayedEventAction,
UpdateDelayedEventAction,
} from "./@types/requests.ts";
import { EventType, type StateEvents } from "./@types/event.ts";
import { logger } from "./logger.ts";
@@ -459,8 +459,12 @@ export class RoomWidgetClient extends MatrixClient {
/**
* @experimental This currently relies on an unstable MSC (MSC4140).
* @deprecated Instead use one of:
* - {@link _unstable_cancelScheduledDelayedEvent}
* - {@link _unstable_restartScheduledDelayedEvent}
* - {@link _unstable_sendScheduledDelayedEvent}
*/
// eslint-disable-next-line
// eslint-disable-next-line @typescript-eslint/naming-convention
public async _unstable_updateDelayedEvent(delayId: string, action: UpdateDelayedEventAction): Promise<EmptyObject> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw new UnsupportedDelayedEventsEndpointError(
@@ -469,7 +473,67 @@ export class RoomWidgetClient extends MatrixClient {
);
}
await this.widgetApi.updateDelayedEvent(delayId, action).catch(timeoutToConnectionError);
let updateDelayedEvent: (delayId: string) => Promise<unknown>;
switch (action) {
case UpdateDelayedEventAction.Cancel:
updateDelayedEvent = this.widgetApi.cancelScheduledDelayedEvent;
break;
case UpdateDelayedEventAction.Restart:
updateDelayedEvent = this.widgetApi.cancelScheduledDelayedEvent;
break;
case UpdateDelayedEventAction.Send:
updateDelayedEvent = this.widgetApi.sendScheduledDelayedEvent;
break;
}
await updateDelayedEvent.call(this.widgetApi, delayId).catch(timeoutToConnectionError);
return {};
}
/**
* @experimental This currently relies on an unstable MSC (MSC4140).
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
public async _unstable_cancelScheduledDelayedEvent(delayId: string): Promise<EmptyObject> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
"cancelScheduledDelayedEvent",
);
}
await this.widgetApi.cancelScheduledDelayedEvent(delayId).catch(timeoutToConnectionError);
return {};
}
/**
* @experimental This currently relies on an unstable MSC (MSC4140).
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
public async _unstable_restartScheduledDelayedEvent(delayId: string): Promise<EmptyObject> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
"restartScheduledDelayedEvent",
);
}
await this.widgetApi.restartScheduledDelayedEvent(delayId).catch(timeoutToConnectionError);
return {};
}
/**
* @experimental This currently relies on an unstable MSC (MSC4140).
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
public async _unstable_sendScheduledDelayedEvent(delayId: string): Promise<EmptyObject> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
"sendScheduledDelayedEvent",
);
}
await this.widgetApi.sendScheduledDelayedEvent(delayId).catch(timeoutToConnectionError);
return {};
}
+8 -1
View File
@@ -59,7 +59,14 @@ export class ClientStoppedError extends Error {
export class UnsupportedDelayedEventsEndpointError extends Error {
public constructor(
message: string,
public clientEndpoint: "sendDelayedEvent" | "updateDelayedEvent" | "sendDelayedStateEvent" | "getDelayedEvents",
public clientEndpoint:
| "sendDelayedEvent"
| "updateDelayedEvent"
| "cancelScheduledDelayedEvent"
| "restartScheduledDelayedEvent"
| "sendScheduledDelayedEvent"
| "sendDelayedStateEvent"
| "getDelayedEvents",
) {
super(message);
this.name = "UnsupportedDelayedEventsEndpointError";
+2 -4
View File
@@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Optional } from "matrix-events-sdk";
import { ExtensibleEvent } from "./ExtensibleEvent.ts";
import {
type ExtensibleEventType,
@@ -46,7 +44,7 @@ export class MessageEvent extends ExtensibleEvent<ExtensibleAnyMessageEventConte
/**
* The default HTML for the event, if provided.
*/
public readonly html: Optional<string>;
public readonly html?: string;
/**
* All the different renderings of the message. Note that this is the same
@@ -82,7 +80,7 @@ export class MessageEvent extends ExtensibleEvent<ExtensibleAnyMessageEventConte
this.renderings = mmessage;
} else if (isOptionalAString(mtext)) {
this.text = mtext;
this.html = mhtml;
this.html = mhtml ?? undefined;
this.renderings = [{ body: mtext, mimetype: "text/plain" }];
if (this.html) {
this.renderings.push({ body: this.html, mimetype: "text/html" });
+8 -1
View File
@@ -279,6 +279,13 @@ export class FetchHttpApi<O extends IHttpOpts> {
const { signal, cleanup } = anySignal(signals);
// Set cache mode based on presence of Authorization header.
// Browsers/proxies do not cache responses to requests with Authorization headers.
// So specifying "no-cache" is redundant, and actually prevents caching
// of preflight requests in CORS scenarios. As such, we only set "no-cache"
// when there is no Authorization header.
const cacheMode = "Authorization" in headers ? undefined : "no-cache";
let res: Response;
const start = Date.now();
try {
@@ -291,7 +298,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
redirect: "follow",
referrer: "",
referrerPolicy: "no-referrer",
cache: "no-cache",
cache: cacheMode,
credentials: "omit", // we send credentials via headers
keepalive: keepAlive,
priority: opts.priority,
+5 -1
View File
@@ -178,7 +178,8 @@ export interface MembershipConfig {
* In the presence of network packet loss (hurting TCP connections), the custom delayedEventRestartLocalTimeoutMs
* helps by keeping more delayed event reset candidates in flight,
* improving the chances of a successful reset. (its is equivalent to the js-sdk `localTimeout` configuration,
* but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
* but only applies to calls to the `_unstable_restartScheduledDelayedEvent` endpoint
* or the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
*/
delayedLeaveEventRestartLocalTimeoutMs?: number;
@@ -514,6 +515,9 @@ export class MatrixRTCSession extends TypedEventEmitter<
| "sendStateEvent"
| "_unstable_sendDelayedStateEvent"
| "_unstable_updateDelayedEvent"
| "_unstable_cancelScheduledDelayedEvent"
| "_unstable_restartScheduledDelayedEvent"
| "_unstable_sendScheduledDelayedEvent"
| "_unstable_sendStickyEvent"
| "_unstable_sendStickyDelayedEvent"
| "cancelPendingEvent"
+15 -15
View File
@@ -16,11 +16,7 @@ limitations under the License.
import { AbortError } from "p-retry";
import { EventType, RelationType } from "../@types/event.ts";
import {
type ISendEventResponse,
type SendDelayedEventResponse,
UpdateDelayedEventAction,
} from "../@types/requests.ts";
import { type ISendEventResponse, type SendDelayedEventResponse } from "../@types/requests.ts";
import { type EmptyObject } from "../@types/common.ts";
import type { MatrixClient } from "../client.ts";
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
@@ -169,7 +165,14 @@ function createReplaceActionUpdate(type: MembershipActionType, offset?: number):
type MembershipManagerClient = Pick<
MatrixClient,
"getUserId" | "getDeviceId" | "sendStateEvent" | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent"
| "getUserId"
| "getDeviceId"
| "sendStateEvent"
| "_unstable_sendDelayedStateEvent"
| "_unstable_updateDelayedEvent"
| "_unstable_cancelScheduledDelayedEvent"
| "_unstable_restartScheduledDelayedEvent"
| "_unstable_sendScheduledDelayedEvent"
>;
/**
@@ -544,7 +547,7 @@ export class MembershipManager
private async cancelKnownDelayIdBeforeSendDelayedEvent(delayId: string): Promise<ActionUpdate> {
// Remove all running updates and restarts
return await this.client
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Cancel)
._unstable_cancelScheduledDelayedEvent(delayId)
.then(() => {
this.state.delayId = undefined;
this.resetRateLimitCounter(MembershipActionType.SendDelayedEvent);
@@ -552,7 +555,7 @@ export class MembershipManager
})
.catch((e) => {
const repeatActionType = MembershipActionType.SendDelayedEvent;
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
const update = this.actionUpdateFromErrors(e, repeatActionType, "cancelScheduledDelayedEvent");
if (update) return update;
if (this.isNotFoundError(e)) {
@@ -606,10 +609,7 @@ export class MembershipManager
// The obvious choice here would be to use the `IRequestOpts` to set the timeout. Since this call might be forwarded
// to the widget driver this information would get lost. That is why we mimic the AbortError using the race.
return await Promise.race([
this.client._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Restart),
abortPromise,
])
return await Promise.race([this.client._unstable_restartScheduledDelayedEvent(delayId), abortPromise])
.then(() => {
// Whenever we successfully restart the delayed event we update the `state.expectedServerDelayLeaveTs`
// which stores the predicted timestamp at which the server will send the delayed leave event if there wont be any further
@@ -637,7 +637,7 @@ export class MembershipManager
if (this.isUnsupportedDelayedEndpoint(e)) return {};
// TODO this also needs a test: get rate limit while checking id delayed event is scheduled
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
const update = this.actionUpdateFromErrors(e, repeatActionType, "restartScheduledDelayedEvent");
if (update) return update;
// In other error cases we have no idea what is happening
@@ -647,7 +647,7 @@ export class MembershipManager
private async sendScheduledDelayedLeaveEventOrFallbackToSendLeaveEvent(delayId: string): Promise<ActionUpdate> {
return await this.client
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Send)
._unstable_sendScheduledDelayedEvent(delayId)
.then(() => {
this.state.hasMemberStateEvent = false;
this.resetRateLimitCounter(MembershipActionType.SendScheduledDelayedLeaveEvent);
@@ -661,7 +661,7 @@ export class MembershipManager
this.state.delayId = undefined;
return createInsertActionUpdate(repeatActionType);
}
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
const update = this.actionUpdateFromErrors(e, repeatActionType, "sendScheduledDelayedEvent");
if (update) return update;
// On any other error we fall back to SendLeaveEvent (this includes hard errors from rate limiting)
+7 -7
View File
@@ -19,7 +19,7 @@ limitations under the License.
* the public classes.
*/
import { type ExtensibleEvent, ExtensibleEvents, type Optional } from "matrix-events-sdk";
import { type ExtensibleEvent, ExtensibleEvents } from "matrix-events-sdk";
import type { IEventDecryptionResult } from "../@types/crypto.ts";
import { logger } from "../logger.ts";
@@ -270,7 +270,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
// addition to a falsy cached event value. We check the flag later on in
// a public getter to decide if the cache is valid.
private _hasCachedExtEv = false;
private _cachedExtEv: Optional<ExtensibleEvent> = undefined;
private _cachedExtEv?: ExtensibleEvent = undefined;
/** If we failed to decrypt this event, the reason for the failure. Otherwise, `null`. */
private _decryptionFailureReason: DecryptionFailureCode | null = null;
@@ -481,9 +481,9 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
*
* @deprecated Use stable functions where possible.
*/
public get unstableExtensibleEvent(): Optional<ExtensibleEvent> {
public get unstableExtensibleEvent(): ExtensibleEvent | undefined {
if (!this._hasCachedExtEv) {
this._cachedExtEv = ExtensibleEvents.parse(this.getEffectiveEvent());
this._cachedExtEv = ExtensibleEvents.parse(this.getEffectiveEvent()) ?? undefined;
}
return this._cachedExtEv;
}
@@ -753,7 +753,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
/**
* Get the event state_key if it has one. If necessary, this will perform
* string-unpacking on the state key, as per MSC3414. This will return
* string-unpacking on the state key, as per MSC4362. This will return
* <code>undefined</code> for message events.
* @returns The event's `state_key`.
*/
@@ -766,7 +766,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
/**
* Get the raw event state_key if it has one. This may be string-packed as per
* MSC3414 if the state event is encrypted. This will return <code>undefined
* MSC4362 if the state event is encrypted. This will return <code>undefined
* </code> for message events.
* @returns The event's `state_key`.
*/
@@ -789,7 +789,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
* @returns The user's room membership, or `undefined` if the server does
* not report it.
*/
public getMembershipAtEvent(): Optional<Membership | string> {
public getMembershipAtEvent(): Membership | string | undefined {
const unsigned = this.getUnsigned();
return UNSIGNED_MEMBERSHIP_FIELD.findIn<Membership | string>(unsigned);
}
+15 -20
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { M_POLL_START, type Optional } from "matrix-events-sdk";
import { M_POLL_START } from "matrix-events-sdk";
import {
DuplicateStrategy,
@@ -1196,7 +1196,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// Get the main TimelineSet
const timelineSet = this.getUnfilteredTimelineSet();
let newTimeline: Optional<EventTimeline>;
let newTimeline: EventTimeline | null = null;
// If there isn't any event in the timeline, let's go fetch the latest
// event and construct a timeline from it.
//
@@ -1783,12 +1783,11 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
allowDefault = true,
useAuthentication: boolean = false,
): string | null {
const roomAvatarEvent = this.currentState.getStateEvents(EventType.RoomAvatar, "");
if (!roomAvatarEvent && !allowDefault) {
const mainUrl = this.getMxcAvatarUrl();
if (!mainUrl && !allowDefault) {
return null;
}
const mainUrl = roomAvatarEvent ? roomAvatarEvent.getContent().url : null;
if (mainUrl) {
return getHttpUriForMxc(
baseUrl,
@@ -1810,7 +1809,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* @returns the mxc avatar url or falsy
*/
public getMxcAvatarUrl(): string | null {
return this.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent()?.url || null;
const url = this.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent().url;
return url && typeof url === "string" ? url : null;
}
/**
@@ -1820,11 +1820,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* @returns The room's canonical alias, or null if there is none
*/
public getCanonicalAlias(): string | null {
const canonicalAlias = this.currentState.getStateEvents(EventType.RoomCanonicalAlias, "");
if (canonicalAlias) {
return canonicalAlias.getContent().alias || null;
}
return null;
const canonicalAlias = this.currentState.getStateEvents(EventType.RoomCanonicalAlias, "")?.getContent().alias;
return canonicalAlias && typeof canonicalAlias === "string" ? canonicalAlias : null;
}
/**
@@ -1832,9 +1829,9 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* @returns The room's alternative aliases, or an empty array
*/
public getAltAliases(): string[] {
const canonicalAlias = this.currentState.getStateEvents(EventType.RoomCanonicalAlias, "");
if (canonicalAlias) {
return canonicalAlias.getContent().alt_aliases || [];
const altAliases = this.currentState.getStateEvents(EventType.RoomCanonicalAlias, "")?.getContent().alt_aliases;
if (Array.isArray(altAliases)) {
return altAliases.filter((alias) => typeof alias === "string");
}
return [];
}
@@ -2493,7 +2490,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
};
private updateThreadRootEvent = (
timelineSet: Optional<EventTimelineSet>,
timelineSet: EventTimelineSet | undefined,
thread: Thread,
toStartOfTimeline: boolean,
recreateEvent: boolean,
@@ -3640,13 +3637,11 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
*/
private calculateRoomName(userId: string, ignoreRoomNameEvent = false): string {
if (!ignoreRoomNameEvent) {
// check for an alias, if any. for now, assume first alias is the
// official one.
const mRoomName = this.currentState.getStateEvents(EventType.RoomName, "");
if (mRoomName?.getContent().name) {
const name = this.currentState.getStateEvents(EventType.RoomName, "")?.getContent().name;
if (name && typeof name === "string") {
return this.roomNameGenerator({
type: RoomNameType.Actual,
name: mRoomName.getContent().name,
name,
});
}
}
+4 -6
View File
@@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Optional } from "matrix-events-sdk";
import { type MatrixClient, PendingEventOrdering } from "../client.ts";
import { TypedReEmitter } from "../ReEmitter.ts";
import { RelationType } from "../@types/event.ts";
@@ -476,7 +474,7 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
}
}
public async processEvent(event: Optional<MatrixEvent>): Promise<void> {
public async processEvent(event: MatrixEvent | null | undefined): Promise<void> {
if (event) {
this.setEventMetadata(event);
await this.fetchEditsWhereNeeded(event);
@@ -686,14 +684,14 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
}
}
public setEventMetadata(event: Optional<MatrixEvent>): void {
public setEventMetadata(event: MatrixEvent | null | undefined): void {
if (event) {
EventTimeline.setEventMetadata(event, this.roomState, false);
event.setThread(this);
}
}
public clearEventMetadata(event: Optional<MatrixEvent>): void {
public clearEventMetadata(event: MatrixEvent | null | undefined): void {
if (event) {
event.setThread(undefined);
delete event.event?.unsigned?.["m.relations"]?.[THREAD_RELATION_TYPE.name];
@@ -739,7 +737,7 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
* A getter for the last event of the thread.
* This might be a synthesized event, if so, it will not emit any events to listeners.
*/
public get replyToEvent(): Optional<MatrixEvent> {
public get replyToEvent(): MatrixEvent | null {
return this.lastPendingEvent ?? this.lastEvent ?? this.lastReply();
}
+68 -5
View File
@@ -129,6 +129,9 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
/** mapping of roomId → encryptor class */
private roomEncryptors: Record<string, RoomEncryptor> = {};
/** mapping of room ID -> inviter ID for rooms pending MSC4268 key bundles */
private readonly roomsPendingKeyBundles: Map<string, string> = new Map();
private eventDecryptor: EventDecryptor;
private keyClaimManager: KeyClaimManager;
private outgoingRequestProcessor: OutgoingRequestProcessor;
@@ -164,7 +167,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
/** Crypto callbacks provided by the application */
private readonly cryptoCallbacks: CryptoCallbacks,
/** Enable support for encrypted state events under MSC3414. */
/** Enable support for encrypted state events under MSC4362. */
private readonly enableEncryptedStateEvents: boolean = false,
) {
super();
@@ -329,10 +332,9 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
/**
* Implementation of {@link CryptoBackend.maybeAcceptKeyBundle}.
*/
public async maybeAcceptKeyBundle(roomId: string, inviter: string): Promise<void> {
public async maybeAcceptKeyBundle(roomId: string, inviter: string): Promise<boolean> {
// TODO: retry this if it gets interrupted or it fails. (https://github.com/matrix-org/matrix-rust-sdk/issues/5112)
// TODO: do this in the background.
// TODO: handle the bundle message arriving after the invite (https://github.com/element-hq/element-web/issues/30740)
const logger = new LogSpan(this.logger, `maybeAcceptKeyBundle(${roomId}, ${inviter})`);
@@ -352,7 +354,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
);
if (!bundleData) {
logger.info("No key bundle found for user");
return;
return false;
}
logger.info(`Fetching key bundle ${bundleData.url}`);
@@ -391,7 +393,17 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
logger.warn(`Error receiving encrypted bundle:`, err);
throw err;
}
return true;
}
/**
* Implementation of {@link CryptoBackend.markRoomAsPendingKeyBundle}.
*/
public markRoomAsPendingKeyBundle(roomId: string, inviter: string): void {
this.roomsPendingKeyBundles.set(roomId, inviter);
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// CryptoApi implementation
@@ -1703,6 +1715,37 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
senderVerified: encryptionInfo.isSenderVerified(),
},
});
// If we have received a room key bundle message, and have previously marked the room
// IDs it references as pending key bundles, tell the Rust SDK to try and accept it,
// just in case it was received after invite.
//
// We don't actually need to validate the contents of the bundle message, or do
// anything with its contents at all. We simply want to inform the Rust SDK we have
// received a new room key bundle that we might be able to download.
if (
isRoomKeyBundleMessage(parsedMessage) &&
this.roomsPendingKeyBundles.has(parsedMessage.content.room_id)
) {
// No `await`-ing here, as this is called from inside the `/sync` loop.
this.maybeAcceptKeyBundle(
parsedMessage.content.room_id,
this.roomsPendingKeyBundles.get(parsedMessage.content.room_id)!,
).then(
(success) => {
if (success) {
this.roomsPendingKeyBundles.delete(parsedMessage.content.room_id);
}
},
(err) => {
this.logger.error(
`Error attempting to download key bundle for room ${parsedMessage.content.room_id}`,
);
this.logger.error(err);
},
);
}
break;
}
case RustSdkCryptoJs.ProcessedToDeviceEventType.PlainText: {
@@ -1774,7 +1817,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
return;
}
if (config["io.element.msc3414.encrypt_state_events"] && this.enableEncryptedStateEvents) {
if (config["io.element.msc4362.encrypt_state_events"] && this.enableEncryptedStateEvents) {
this.logger.info("crypto Enabling state event encryption...");
settings.encryptStateEvents = true;
}
@@ -2468,5 +2511,25 @@ function rustEncryptionInfoToJsEncryptionInfo(
return { shieldColour, shieldReason };
}
interface RoomKeyBundleMessage {
type: "io.element.msc4268.room_key_bundle";
content: {
room_id: string;
};
}
/**
* Determines if the given payload is a RoomKeyBundleMessage.
*
* A RoomKeyBundleMessage is identified by having a specific message type
* ("io.element.msc4268.room_key_bundle") and a valid room_id in its content.
*
* @param message - The received to-device message to check.
* @returns True if the payload matches the RoomKeyBundleMessage structure, false otherwise.
*/
function isRoomKeyBundleMessage(message: IToDeviceEvent): message is IToDeviceEvent & RoomKeyBundleMessage {
return message.type === "io.element.msc4268.room_key_bundle" && typeof message.content.room_id === "string";
}
type CryptoEvents = (typeof CryptoEvent)[keyof typeof CryptoEvent];
type RustCryptoEvents = Exclude<CryptoEvents, CryptoEvent.LegacyCryptoStoreMigrationProgress>;
+1 -3
View File
@@ -23,8 +23,6 @@ limitations under the License.
* for HTTP and WS at some point.
*/
import { type Optional } from "matrix-events-sdk";
import type { SyncCryptoCallbacks } from "./common-crypto/CryptoBackend.ts";
import { User } from "./models/user.ts";
import { NotificationCountType, Room, RoomEvent } from "./models/room.ts";
@@ -208,7 +206,7 @@ export class SyncApi {
private readonly opts: IStoredClientOpts;
private readonly syncOpts: SyncApiOptions;
private _peekRoom: Optional<Room> = null;
private _peekRoom: Room | null = null;
private currentSyncRequest?: Promise<ISyncResponse>;
private abortController?: AbortController;
private syncState: SyncState | null = null;
+1 -3
View File
@@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Optional } from "matrix-events-sdk";
import { type Direction, EventTimeline } from "./models/event-timeline.ts";
import { logger } from "./logger.ts";
import { type MatrixClient } from "./client.ts";
@@ -105,7 +103,7 @@ export class TimelineWindow {
public load(initialEventId?: string, initialWindowSize = 20): Promise<void> {
// given an EventTimeline, find the event we were looking for, and initialise our
// fields so that the event in question is in the middle of the window.
const initFields = (timeline: Optional<EventTimeline>): void => {
const initFields = (timeline: EventTimeline | null): void => {
if (!timeline) {
throw new Error("No timeline given to initFields");
}
+1 -2
View File
@@ -20,7 +20,6 @@ limitations under the License.
import unhomoglyph from "unhomoglyph";
import promiseRetry from "p-retry";
import { type Optional } from "matrix-events-sdk";
import { type IEvent, type MatrixEvent } from "./models/event.ts";
import { M_TIMESTAMP } from "./@types/location.ts";
@@ -115,7 +114,7 @@ export function decodeParams(query: string): Record<string, string | string[]> {
* variables with. E.g. `{ "$bar": "baz" }`.
* @returns The result of replacing all template variables e.g. '/foo/baz'.
*/
export function encodeUri(pathTemplate: string, variables: Record<string, Optional<string>>): string {
export function encodeUri(pathTemplate: string, variables: Record<string, string | null | undefined>): string {
for (const key in variables) {
if (!variables.hasOwnProperty(key)) {
continue;
+40 -23
View File
@@ -264,8 +264,19 @@ export class MediaHandler extends TypedEventEmitter<
}
if (!canReuseStream) {
const constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo);
stream = await navigator.mediaDevices.getUserMedia(constraints);
let constraints: MediaStreamConstraints;
try {
// Not specifying exact for deviceId means switching devices does not always work,
// try with exact and fallback to ideal if it fails
constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo, true);
stream = await navigator.mediaDevices.getUserMedia(constraints);
} catch (e) {
logger.warn(
`MediaHandler getUserMediaStreamInternal() error (e=${e}), retrying without exact deviceId`,
);
constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo, false);
stream = await navigator.mediaDevices.getUserMedia(constraints);
}
logger.log(
`MediaHandler getUserMediaStreamInternal() calling getUserMediaStream (streamId=${
stream.id
@@ -435,30 +446,36 @@ export class MediaHandler extends TypedEventEmitter<
this.emit(MediaHandlerEvent.LocalStreamsChanged);
}
private getUserMediaContraints(audio: boolean, video: boolean): MediaStreamConstraints {
private getUserMediaContraints(audio: boolean, video: boolean, exactDeviceId?: boolean): MediaStreamConstraints {
const isWebkit = !!navigator.webkitGetUserMedia;
const deviceIdKey = exactDeviceId ? "exact" : "ideal";
const audioConstraints: MediaTrackConstraints = {};
if (this.audioInput) {
audioConstraints.deviceId = { [deviceIdKey]: this.audioInput };
}
if (this.audioSettings) {
audioConstraints.autoGainControl = { ideal: this.audioSettings.autoGainControl };
audioConstraints.echoCancellation = { ideal: this.audioSettings.echoCancellation };
audioConstraints.noiseSuppression = { ideal: this.audioSettings.noiseSuppression };
}
const videoConstraints: MediaTrackConstraints = {
/* We want 640x360. Chrome will give it only if we ask exactly,
FF refuses entirely if we ask exactly, so have to ask for ideal
instead
XXX: Is this still true?
*/
width: isWebkit ? { exact: 640 } : { ideal: 640 },
height: isWebkit ? { exact: 360 } : { ideal: 360 },
};
if (this.videoInput) {
videoConstraints.deviceId = { [deviceIdKey]: this.videoInput };
}
return {
audio: audio
? {
deviceId: this.audioInput ? { ideal: this.audioInput } : undefined,
autoGainControl: this.audioSettings ? { ideal: this.audioSettings.autoGainControl } : undefined,
echoCancellation: this.audioSettings ? { ideal: this.audioSettings.echoCancellation } : undefined,
noiseSuppression: this.audioSettings ? { ideal: this.audioSettings.noiseSuppression } : undefined,
}
: false,
video: video
? {
deviceId: this.videoInput ? { ideal: this.videoInput } : undefined,
/* We want 640x360. Chrome will give it only if we ask exactly,
FF refuses entirely if we ask exactly, so have to ask for ideal
instead
XXX: Is this still true?
*/
width: isWebkit ? { exact: 640 } : { ideal: 640 },
height: isWebkit ? { exact: 360 } : { ideal: 360 },
}
: false,
audio: audio ? audioConstraints : false,
video: video ? videoConstraints : false,
};
}
+1476 -1503
View File
File diff suppressed because it is too large Load Diff