Compare commits

..

55 Commits

Author SHA1 Message Date
RiotRobot ef97df8ed0 v38.3.0 2025-09-23 12:12:22 +00:00
RiotRobot 0ce751c462 v38.3.0-rc.0 2025-09-16 14:09:05 +00:00
RiotRobot 2706873948 Merge branch 'master' into develop 2025-09-16 11:40:18 +00:00
RiotRobot 4a9006aea6 v38.2.0 2025-09-16 11:39:28 +00:00
Michael Telatynski 43c72d5bf5 Merge commit from fork
* Validate room upgrade relationships in MatrixClient::getJoinedRooms

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

* Tests

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-09-16 12:36:14 +01:00
Andy Balaam e551b92a07 Update matrix-sdk-crypto-wasm to 15.2.0 (#4991)
* Update matrix-sdk-crypto-wasm to 15.2.0

Most relevant changes:

-   History sharing: improve efficiency of building key bundle
    ([matrix-rust-sdk#5513](https://github.com/matrix-org/matrix-rust-sdk/issues/5513))

* Work around matrix-rust-sdk#5643

Modify the message content coming from Rust API to include the missing
property `msgtype: m.key.verification.request`
2025-09-11 14:53:13 +00:00
Richard van der Hoff 32f51e852b History sharing: do /keys/query before checking for key bundle (#4992)
* History sharing: do `/keys/query` before checking for key bundle

The next release of matrix-sdk-crypto-wasm will check that the device that sent
us the key bundle data was correctly cross-signed by its owner, which means we
need to have the owner's cross-signing keys before we check if we have the
bundle.

This replicates a change made in the Rust SDK, at https://github.com/matrix-org/matrix-rust-sdk/pull/5510/files#diff-9f89fa75c4eb3743ae674be1bb90f75169bd815a259917799c71b8a546449d51R133-R140.

* fix unit test

* Comment
2025-09-11 10:38:14 +00:00
RiotRobot 82aa04d894 Merge branch 'master' into develop 2025-09-09 16:30:39 +00:00
RiotRobot ff89c9ec42 v38.1.0 2025-09-09 16:29:55 +00:00
Svajūnas Budrys ccd825fb39 Remove knock state on join (#4977)
Signed-off-by: Svajunas Budrys <svajunas.budrys.sb@gmail.com>
2025-09-04 09:31:26 +00:00
renovate[bot] b313eb5912 Update dependency @types/sdp-transform to v2.15.0 (#4987)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-03 09:06:51 +00:00
renovate[bot] 2b12675675 Update typescript-eslint monorepo to v8.41.0 (#4989)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 17:14:06 +00:00
renovate[bot] 246788b874 Update shogo82148/actions-upload-release-asset digest to e6cd457 (#4985)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 17:13:57 +00:00
renovate[bot] b0b80401aa Update actions/upload-pages-artifact action to v4 (#4990)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 13:39:36 +00:00
renovate[bot] 6a0164f37f Update dependency knip to v5.63.0 (#4988)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 13:30:21 +00:00
renovate[bot] f963d61bcb Update typedoc (#4986)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 13:29:33 +00:00
RiotRobot b32619ad24 v38.1.0-rc.0 2025-09-02 12:53:40 +00:00
Tulir Asokan 3d3c3ba55f Fix m.topic format (#4984)
* Fix m.topic format

Fixes #4902

Signed-off-by: Tulir Asokan <tulir@maunium.net>

* Update tests

Signed-off-by: Tulir Asokan <tulir@maunium.net>

* Fix formatting

* Re-add temporary support for invalid form

Signed-off-by: Tulir Asokan <tulir@maunium.net>

---------

Signed-off-by: Tulir Asokan <tulir@maunium.net>
2025-09-02 12:14:08 +00:00
Timo d62c658a72 Remove custom org.matrix.msc4075.rtc.notification.parent relation type (#4979)
* Remove custom `org.matrix.msc4075.rtc.notification.parent` relation type

Signed-off-by: Timo K <toger5@hotmail.de>

* fix test

Signed-off-by: Timo K <toger5@hotmail.de>

* Fix test post rebase

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Valere <bill.carson@valrsoft.com>
2025-09-02 08:36:26 +00:00
David Baker bdc4a69023 Fix stable-suffixed MSC4133 support (#4983)
* Fix stable-suffixed MSC4133 support

It looked for the ".stable" suffixed feature to work out what URL to use but not to see whether the server supported it.

This will only be relevant until the next spec release but may as well fix it.

See also https://github.com/element-hq/element-web/pull/30649

* Fix awaiting
2025-09-01 15:34:01 +01:00
Bas Nijholt ab892420b5 Fix thread edit aggregation race condition (#4980)
* test(thread): add regression tests for edit-race; ensure reaction aggregation idempotence

- Edit race: add failing test when `Replace` aggregated pre-init
- Reaction: ensure pre-init aggregation and dedup on replay
- Strengthen assertions for ordering and content

* fix(thread): apply edits after init; keep reactions pre-init; remove redundant aggregation
- Defer Replace aggregation until thread initialised and event is in timeline
- Aggregate Annotation pre-init to preserve reaction summaries
- Rely on EventTimelineSet to aggregate post-insert
- Fixes: element-hq/element-web#30617

* style: run prettier; docs: clarify reaction pre-init comment about counts
2025-08-28 09:03:33 +00:00
RiotRobot ed607c48b0 Merge branch 'master' into develop 2025-08-27 13:32:25 +00:00
RiotRobot a8d75b81e5 v38.0.0 2025-08-27 13:31:35 +00:00
Timo 2f1d654f14 MatrixRTC: Add RTC decline event (#4978)
* Add decline event

Signed-off-by: Timo K <toger5@hotmail.de>

* add `client.sendRtcDecline`.

Signed-off-by: Timo K <toger5@hotmail.de>

* remove `decline.reason` field.

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 15:21:40 +00:00
Timo c4c7f94514 Make a MatrixRTCSession emit once the RTCNotification is sent (#4976)
* MatrixRTCSession emits once the rtc notification is sent.

Signed-off-by: Timo K <toger5@hotmail.de>

* update correct type description

Signed-off-by: Timo K <toger5@hotmail.de>

* Add test

Signed-off-by: Timo K <toger5@hotmail.de>

* fix imports

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 15:16:02 +00:00
David Baker 1fac06e223 Use hydra semantics for unknown room versions (#4957)
This inverts the check for whether to use hydra semantics to only NOT use
it for known, old room versions and use hydra for everything else, so
rooms with versions we don't know about will use hydra semantics.

This will cause any rooms using old/experiental versions unknown to
the js-sdk to break, but will mean that wehn the next room version
comes out, we'll use hydra for it which is, of course, not a given,
but is way more likely than going back to the old semantics.

The mobile Element clients currently hardcode hydra versions (ie.
as it is without this change, but we expect them to make this same
change soon after the hydra release.

We do NOT expect this to land with the hydra release, but target it for
the release after.

Reverts 1e5054a8ff87f83b0875916aa16f435853bf165a from https://github.com/matrix-org/matrix-js-sdk/pull/4937

See https://github.com/element-hq/element-meta/issues/2921 for public
discussion.
2025-08-26 10:49:44 +00:00
RiotRobot 77c118084b v38.0.0-rc.1 2025-08-21 14:16:23 +00:00
ElementRobot 097bfe451a Release tranche of breaking changes (#4963) (#4975)
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
Co-authored-by: Richard van der Hoff <richard@matrix.org>
2025-08-21 15:08:48 +01:00
Michael Telatynski b80d0091d2 Release tranche of breaking changes (#4963)
* Remove deprecated `IJoinRoomOpts.syncRoom` option (#4914)

This option is non-functional, and was deprecated in
https://github.com/matrix-org/matrix-js-sdk/pull/4913. Remove it altogether.

* Remove support for `onlyData != true` (#4939)

* Remove deprecated fields, methods, utilities (#4959)

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
Co-authored-by: Richard van der Hoff <richard@matrix.org>
2025-08-21 13:24:36 +00:00
Timo 3a33c658bb Expose the StatusChanged event through the RTCSession (#4974)
* Expose the StatusChanged event through the RTCSession

Signed-off-by: Timo K <toger5@hotmail.de>

* add membershipManagerStatus public get field

Signed-off-by: Timo K <toger5@hotmail.de>

* add probably left as a getter

Signed-off-by: Timo K <toger5@hotmail.de>

* add tests for coverage

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-20 16:48:56 +00:00
Timo 81e42b9531 Add probablyLeft event to the MatrixRTCSession (#4962)
* Add probablyLeft emission to the MatrixRTCSession

Signed-off-by: Timo K <toger5@hotmail.de>

* add docstring

Signed-off-by: Timo K <toger5@hotmail.de>

* Review: add additional test + fix pending promises issue.

Signed-off-by: Timo K <toger5@hotmail.de>

* review: `Pick` only a subset of membership manager events

Signed-off-by: Timo K <toger5@hotmail.de>

* reveiw: update probablyLeft logic to be more straight forward

Signed-off-by: Timo K <toger5@hotmail.de>

* fix test

Signed-off-by: Timo K <toger5@hotmail.de>

* make test not wait for 5s

Signed-off-by: Timo K <toger5@hotmail.de>

* review

Signed-off-by: Timo K <toger5@hotmail.de>

* fix linter (rebase)

Signed-off-by: Timo K <toger5@hotmail.de>

* fix import

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-20 12:05:07 +00:00
renovate[bot] 7f7ecd060d Update dependency lint-staged to v16.1.5 (#4969)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 15:06:35 +00:00
renovate[bot] 6126ee125a Update typedoc (#4970)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-08-19 14:27:08 +00:00
renovate[bot] 78f718ff82 Update typescript-eslint monorepo to v8.39.1 (#4971)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:27:09 +00:00
renovate[bot] c1c1be0c5d Update actions/download-artifact action to v5 (#4973)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:26:16 +00:00
renovate[bot] 1952eaa1ff Update actions/checkout action to v5 (#4972)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:26:08 +00:00
renovate[bot] 8851f8b07c Update dependency @types/node to v18.19.123 (#4968)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:24:51 +00:00
renovate[bot] 15eafe34b3 Update babel monorepo to v7.28.3 (#4966)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:24:25 +00:00
renovate[bot] f0d48236fa Update dependency @stylistic/eslint-plugin to v5.2.3 (#4967)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:24:23 +00:00
renovate[bot] dde9d48726 Update shogo82148/actions-upload-release-asset digest to 62365f2 (#4965)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:24:06 +00:00
RiotRobot 6d046edcb2 v38.0.0-rc.0 2025-08-19 13:08:13 +00:00
Timo 2abf7ca795 Allow multiple rtc sessions per room (with different sessionDescriptions) (#4945)
* Introduce sessionDescription

Signed-off-by: Timo K <toger5@hotmail.de>

* Make sessionDescription part of a MatrixRTCSession

Signed-off-by: Timo K <toger5@hotmail.de>

* Make session manager only menage session for one sessionDescription

Signed-off-by: Timo K <toger5@hotmail.de>

* make membership manager aware about session (application + id)
Before this was just hardcoded to a call session

Signed-off-by: Timo K <toger5@hotmail.de>

* update tests

Signed-off-by: Timo K <toger5@hotmail.de>

* fix doc comments

Signed-off-by: Timo K <toger5@hotmail.de>

* Make fields private,  improve comments, improve whitespace, don't use deprecated fields

Signed-off-by: Timo K <toger5@hotmail.de>

* add test for other application end event

Signed-off-by: Timo K <toger5@hotmail.de>

* rename call -> session

Signed-off-by: Timo K <toger5@hotmail.de>

* fix tests

Signed-off-by: Timo K <toger5@hotmail.de>

* remove id check since its already part of `deepCompare(membership.sessionDescription, sessionDescription)`

Signed-off-by: Timo K <toger5@hotmail.de>

* remove scope related tests. The id should be the only thing that scopes sessions. everything else is application (session type) specific

Signed-off-by: Timo K <toger5@hotmail.de>

* review

Signed-off-by: Timo K <toger5@hotmail.de>

* add test for custom sessionDescription

Signed-off-by: Timo K <toger5@hotmail.de>

* callMembershipsForRoom to default to call

Signed-off-by: Timo K <toger5@hotmail.de>

* roomSessionForRoom backwards compatible (And deprecate the call specific method)

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-19 11:58:57 +00:00
Olivier D 2b46579bd8 Add support for login_hint in authorization url generation (#4943)
* add login_hint to authorization url generation

Signed-off-by: olivier <odelcroi@gmail.com>

Signed-off-by: olivier <odelcroi@gmail.com>

fix lint

Signed-off-by: olivier <odelcroi@gmail.com>

* update doc

* fix linter

---------

Signed-off-by: olivier <odelcroi@gmail.com>
Co-authored-by: mcalinghee <mcalinghee.dev@gmail.com>
2025-08-18 15:06:59 +00:00
fkwp 6d42ed338e Only process MatrixRTC sessions associated with calls for callMembershipsForRoom (#4960)
* Only process MatrixRTC sessions associated with calls

* tests: Only process MatrixRTC sessions associated with calls

* linting
2025-08-14 12:44:41 +00:00
RiotRobot ef080c25f9 Merge branch 'master' into develop 2025-08-11 11:13:57 +00:00
RiotRobot c8d7b458b2 v37.13.0 2025-08-11 11:13:14 +00:00
ElementRobot f1ba8a8775 Support v12 rooms in maySendEvent (#4955) (#4956)
Follows on from https://github.com/matrix-org/matrix-js-sdk/pull/4937

(cherry picked from commit dea184e9ec)

Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2025-08-08 15:17:12 +01:00
ElementRobot d21adf568a Support for creator power level (#4937) (#4954)
* Support for creator power level

Adds support for infinite power level specified by [MSC4289](https://github.com/matrix-org/matrix-spec-proposals/pull/4289).

* Update unit test

* Hardcode versions

as room versions strings aren't ordered

* Add test for v12 rooms

* Use more compact syntax



* Fix doc



* Fix additionalCreators from PR edit

* Split out hydra room version check

* Move power level logic into room state

Which already has knowledge of the room create event

* Add docs

* Fix unused bits

* Fix docs

* Fix lying docstring

* Reverse logic for hydra semantics

Assume unknown room versions do use hydra

* Use backticks



* Switch back to hardcoding just the two hydra versions

---------



(cherry picked from commit e119bf9040)

Co-authored-by: David Baker <dbkr@users.noreply.github.com>
Co-authored-by: R Midhun Suresh <hi@midhun.dev>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2025-08-08 15:09:10 +01:00
David Baker dea184e9ec Support v12 rooms in maySendEvent (#4955)
Follows on from https://github.com/matrix-org/matrix-js-sdk/pull/4937
2025-08-08 13:19:51 +00:00
David Baker e119bf9040 Support for creator power level (#4937)
* Support for creator power level

Adds support for infinite power level specified by [MSC4289](https://github.com/matrix-org/matrix-spec-proposals/pull/4289).

* Update unit test

* Hardcode versions

as room versions strings aren't ordered

* Add test for v12 rooms

* Use more compact syntax

Co-authored-by: R Midhun Suresh <hi@midhun.dev>

* Fix doc

Co-authored-by: R Midhun Suresh <hi@midhun.dev>

* Fix additionalCreators from PR edit

* Split out hydra room version check

* Move power level logic into room state

Which already has knowledge of the room create event

* Add docs

* Fix unused bits

* Fix docs

* Fix lying docstring

* Reverse logic for hydra semantics

Assume unknown room versions do use hydra

* Use backticks

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Switch back to hardcoding just the two hydra versions

---------

Co-authored-by: R Midhun Suresh <hi@midhun.dev>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2025-08-08 10:23:49 +00:00
renovate[bot] c7f982e190 Update typedoc (#4949)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 14:25:57 +00:00
renovate[bot] 2e2dd628c1 Update typescript-eslint monorepo to v8.39.0 (#4952)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 13:56:03 +00:00
renovate[bot] 5ac5a8a799 Update dependency @babel/runtime to v7.28.2 (#4950)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 13:55:49 +00:00
renovate[bot] 7d75ab417a Update dependency lint-staged to v16.1.4 (#4948)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 13:40:14 +00:00
renovate[bot] 3ca81e409a Update dependency @types/node to v18.19.121 (#4947)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 13:40:03 +00:00
58 changed files with 2017 additions and 1186 deletions
@@ -22,7 +22,7 @@ runs:
- name: Upload tarball signature
if: ${{ inputs.upload-url }}
uses: shogo82148/actions-upload-release-asset@610b1987249a69a79de9565777e112fb38f22436 # v1
uses: shogo82148/actions-upload-release-asset@e6cd4579c50a5d0eb50ca05d0d597daafca8cebb # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ env.VERSION }}.tar.gz.asc
@@ -29,13 +29,13 @@ runs:
- name: Upload asset signatures
if: inputs.gpg-fingerprint
uses: shogo82148/actions-upload-release-asset@610b1987249a69a79de9565777e112fb38f22436 # v1
uses: shogo82148/actions-upload-release-asset@e6cd4579c50a5d0eb50ca05d0d597daafca8cebb # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ inputs.asset-path }}.asc
- name: Upload assets
uses: shogo82148/actions-upload-release-asset@610b1987249a69a79de9565777e112fb38f22436 # v1
uses: shogo82148/actions-upload-release-asset@e6cd4579c50a5d0eb50ca05d0d597daafca8cebb # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ inputs.asset-path }}
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
deployments: write
steps:
- name: 📥 Download artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
@@ -16,7 +16,7 @@ jobs:
contents: write
steps:
- name: 🧮 Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
ref: staging
fetch-depth: 0
@@ -37,7 +37,7 @@ jobs:
disable-autolabeler: true
- name: Get actions scripts
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
repository: matrix-org/matrix-js-sdk
persist-credentials: false
+2 -2
View File
@@ -73,7 +73,7 @@ jobs:
draft: true
latest: true
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
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 +81,7 @@ jobs:
fetch-depth: 0
- name: Get actions scripts
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
repository: matrix-org/matrix-js-sdk
persist-credentials: false
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
id: ${{ steps.npm-publish.outputs.id }}
steps:
- name: 🧮 Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
ref: staging
+3 -3
View File
@@ -44,7 +44,7 @@ jobs:
repo:
- element-hq/element-web
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
repository: ${{ matrix.repo }}
ref: staging
@@ -73,7 +73,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: 🧮 Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: 🔧 Yarn cache
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
@@ -88,7 +88,7 @@ jobs:
run: yarn gendoc
- name: Upload artifact
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
with:
path: _docs
+3 -3
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
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@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
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@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
if: inputs.sharded
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+7 -7
View File
@@ -14,7 +14,7 @@ jobs:
name: "Typescript Syntax Check"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
@@ -31,7 +31,7 @@ jobs:
name: "ESLint"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
@@ -48,7 +48,7 @@ jobs:
name: "Node.js example"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
@@ -83,7 +83,7 @@ jobs:
name: "Workflow Lint"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
@@ -100,7 +100,7 @@ jobs:
name: "JSDoc Checker"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
@@ -125,7 +125,7 @@ jobs:
name: "Analyse Dead Code"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
@@ -143,7 +143,7 @@ jobs:
if: github.event_name == 'merge_group'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
repository: element-hq/element-web
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
node: ["lts/*", 22]
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Setup Node
id: setupNode
+76
View File
@@ -1,3 +1,79 @@
Changes in [38.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.3.0) (2025-09-23)
==================================================================================================
## 🐛 Bug Fixes
* Remove knocked room when membership changes to join ([#4977](https://github.com/matrix-org/matrix-js-sdk/pull/4977)). Contributed by @svajunas-budrys.
Changes in [38.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.2.0) (2025-09-16)
==================================================================================================
Fix [CVE-2025-59160](https://www.cve.org/CVERecord?id=CVE-2025-59160) / [GHSA-mp7c-m3rh-r56v](https://github.com/matrix-org/matrix-js-sdk/security/advisories/GHSA-mp7c-m3rh-r56v)
Changes in [38.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.1.0) (2025-09-09)
==================================================================================================
## ✨ Features
* Remove custom `org.matrix.msc4075.rtc.notification.parent` relation type ([#4979](https://github.com/matrix-org/matrix-js-sdk/pull/4979)). Contributed by @toger5.
* MatrixRTC: Add RTC decline event ([#4978](https://github.com/matrix-org/matrix-js-sdk/pull/4978)). Contributed by @toger5.
* Make a MatrixRTCSession emit once the RTCNotification is sent ([#4976](https://github.com/matrix-org/matrix-js-sdk/pull/4976)). Contributed by @toger5.
* Use hydra semantics for unknown room versions ([#4957](https://github.com/matrix-org/matrix-js-sdk/pull/4957)). Contributed by @dbkr.
* Expose the StatusChanged event through the RTCSession ([#4974](https://github.com/matrix-org/matrix-js-sdk/pull/4974)). Contributed by @toger5.
* Add `probablyLeft` event to the MatrixRTCSession ([#4962](https://github.com/matrix-org/matrix-js-sdk/pull/4962)). Contributed by @toger5.
## 🐛 Bug Fixes
* Fix `m.topic` format ([#4984](https://github.com/matrix-org/matrix-js-sdk/pull/4984)). Contributed by @tulir.
* Fix stable-suffixed MSC4133 support ([#4983](https://github.com/matrix-org/matrix-js-sdk/pull/4983)). Contributed by @dbkr.
* Fix thread edit aggregation race condition ([#4980](https://github.com/matrix-org/matrix-js-sdk/pull/4980)). Contributed by @basnijholt.
Changes in [38.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.0.0) (2025-08-27)
==================================================================================================
## 🚨 BREAKING CHANGES
* Release tranche of breaking changes ([#4975](https://github.com/matrix-org/matrix-js-sdk/pull/4975)).
* Remove support for FetchHttpApi `onlyData = false`
* Remove deprecated `IJoinRoomOpts.syncRoom`
* Remove deprecated methods which are unsupported in rust crypto
* Remove deprecated getAuthIssuer method
* Remove deprecated beginKeyVerification method
* Remove deprecated isEncryptedDisabledForUnverifiedDevices getter
* Remove deprecated UndecryptableToDeviceEvent MatrixClient emit
* Remove deprecated defer utility method
* Remove deprecated UIAResponse dummy type
* Remove deprecated MatrixRTCSession MembershipConfig fields
* Remove deprecated findVerificationRequestDMInProgress and storeSessionBackupPrivateKey methods in favour of overloads
## ✨ Features
* Allow multiple rtc sessions per room (with different sessionDescriptions) ([#4945](https://github.com/matrix-org/matrix-js-sdk/pull/4945)). Contributed by @toger5.
* Add support for login\_hint in authorization url generation ([#4943](https://github.com/matrix-org/matrix-js-sdk/pull/4943)). Contributed by @odelcroi.
* Only process MatrixRTC sessions associated with calls for `callMembershipsForRoom` ([#4960](https://github.com/matrix-org/matrix-js-sdk/pull/4960)). Contributed by @fkwp.
Changes in [37.13.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.13.0) (2025-08-11)
====================================================================================================
This release supports new v12 Matrix rooms and consequently has a breaking change, removing powerLevelNorm from the RoomMember object as this can't be supported with infinite power levels. Apps should use the non-normalised `powerLevel` instead.
## 🚨 BREAKING CHANGES
* [Backport staging] Support for creator power level ([#4954](https://github.com/matrix-org/matrix-js-sdk/pull/4954)). Contributed by @RiotRobot.
## ✨ Features
* [Backport staging] Support v12 rooms in maySendEvent ([#4956](https://github.com/matrix-org/matrix-js-sdk/pull/4956)). Contributed by @RiotRobot.
* [Backport staging] Support for creator power level ([#4954](https://github.com/matrix-org/matrix-js-sdk/pull/4954)). Contributed by @RiotRobot.
* Experimental support for sharing encrypted history on invite ([#4920](https://github.com/matrix-org/matrix-js-sdk/pull/4920)). Contributed by @richvdh.
* Use the logger associated with MatrixClient in rust sdk ([#4918](https://github.com/matrix-org/matrix-js-sdk/pull/4918)). Contributed by @richvdh.
* Update to matrix-sdk-crypto-wasm 15.1.0, and add new `ShieldStateCode.MismatchedSender` ([#4916](https://github.com/matrix-org/matrix-js-sdk/pull/4916)). Contributed by @richvdh.
## 🐛 Bug Fixes
* Fix unknown/broken state in the RTC Membership Manager causing unnecassary error logging. ([#4944](https://github.com/matrix-org/matrix-js-sdk/pull/4944)). Contributed by @toger5.
Changes in [37.12.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.12.0) (2025-07-29)
====================================================================================================
## 🦖 Deprecations
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "37.13.0-rc.0",
"version": "38.3.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=22.0.0"
@@ -49,7 +49,7 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^15.1.0",
"@matrix-org/matrix-sdk-crypto-wasm": "^15.2.0",
"another-json": "^0.2.0",
"bs58": "^6.0.0",
"content-type": "^1.0.4",
+4 -2
View File
@@ -1491,8 +1491,10 @@ describe("crypto", () => {
expect(ev.decryptionFailureReason).toEqual(expectedErrorCode);
// `isEncryptedDisabledForUnverifiedDevices` should be true for `m.unverified` and false for other errors.
expect(ev.isEncryptedDisabledForUnverifiedDevices).toEqual(withheldCode === "m.unverified");
// `decryptionFailureReason` should be `MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE` for `m.unverified`
expect(
ev.decryptionFailureReason === DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE,
).toEqual(withheldCode === "m.unverified");
});
},
);
+70 -37
View File
@@ -183,39 +183,43 @@ describe("Topic content helpers", () => {
it("creates fully defined event content without html", () => {
expect(makeTopicContent("pizza")).toEqual({
topic: "pizza",
[M_TOPIC.name]: [
{
body: "pizza",
mimetype: "text/plain",
},
],
[M_TOPIC.name]: {
"m.text": [
{
body: "pizza",
mimetype: "text/plain",
},
],
},
});
});
it("creates fully defined event content with html", () => {
expect(makeTopicContent("pizza", "<b>pizza</b>")).toEqual({
topic: "pizza",
[M_TOPIC.name]: [
{
body: "<b>pizza</b>",
mimetype: "text/html",
},
{
body: "pizza",
mimetype: "text/plain",
},
],
[M_TOPIC.name]: {
"m.text": [
{
body: "<b>pizza</b>",
mimetype: "text/html",
},
{
body: "pizza",
mimetype: "text/plain",
},
],
},
});
});
it("creates an empty event when the topic is falsey", () => {
expect(makeTopicContent(undefined)).toEqual({
topic: undefined,
[M_TOPIC.name]: [],
[M_TOPIC.name]: { "m.text": [] },
});
expect(makeTopicContent(null)).toEqual({
topic: null,
[M_TOPIC.name]: [],
[M_TOPIC.name]: { "m.text": [] },
});
});
});
@@ -225,11 +229,13 @@ describe("Topic content helpers", () => {
expect(
parseTopicContent({
topic: "pizza",
[M_TOPIC.name]: [
{
body: "pizza",
},
],
[M_TOPIC.name]: {
"m.text": [
{
body: "pizza",
},
],
},
}),
).toEqual({
text: "pizza",
@@ -240,12 +246,14 @@ describe("Topic content helpers", () => {
expect(
parseTopicContent({
topic: "pizza",
[M_TOPIC.name]: [
{
body: "pizza",
mimetype: "text/plain",
},
],
[M_TOPIC.name]: {
"m.text": [
{
body: "pizza",
mimetype: "text/plain",
},
],
},
}),
).toEqual({
text: "pizza",
@@ -256,12 +264,14 @@ describe("Topic content helpers", () => {
expect(
parseTopicContent({
topic: "pizza",
[M_TOPIC.name]: [
{
body: "<b>pizza</b>",
mimetype: "text/html",
},
],
[M_TOPIC.name]: {
"m.text": [
{
body: "<b>pizza</b>",
mimetype: "text/html",
},
],
},
}),
).toEqual({
text: "pizza",
@@ -279,11 +289,34 @@ describe("Topic content helpers", () => {
});
});
it("uses legacy event content when new topic key is invalid", () => {
// TODO delete this test and re-enable the next one after support for the invalid form is removed
// https://github.com/matrix-org/matrix-js-sdk/pull/4984#pullrequestreview-3174251065
it("parses malformed event content with html topic", () => {
expect(
parseTopicContent({
"topic": "pizza",
"m.topic": {} as any,
"m.topic": [
{
body: "<b>pizza</b>",
mimetype: "text/html",
},
] as any,
}),
).toEqual({
text: "pizza",
html: "<b>pizza</b>",
});
});
it.skip("uses legacy event content when new topic key is invalid", () => {
expect(
parseTopicContent({
"topic": "pizza",
"m.topic": [
{
body: "<b>pizza</b>",
mimetype: "text/html",
},
] as any,
}),
).toEqual({
text: "pizza",
+87 -35
View File
@@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Mocked } from "jest-mock";
import type { Mocked, MockedFunction } from "jest-mock";
import { FetchHttpApi } from "../../../src/http-api/fetch";
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
import {
@@ -43,8 +42,8 @@ describe("FetchHttpApi", () => {
});
it("should support aborting multiple times", () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn });
const fetchFn = makeMockFetchFn();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
api.request(Method.Get, "/foo");
api.request(Method.Get, "/baz");
@@ -68,13 +67,13 @@ describe("FetchHttpApi", () => {
it("should fall back to global fetch if fetchFn not provided", () => {
globalThis.fetch = jest.fn();
expect(globalThis.fetch).not.toHaveBeenCalled();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
api.fetch("test");
expect(globalThis.fetch).toHaveBeenCalled();
});
it("should update identity server base url", () => {
const api = new FetchHttpApi<IHttpOpts>(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new FetchHttpApi<IHttpOpts>(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
expect(api.opts.idBaseUrl).toBeUndefined();
api.setIdBaseUrl("https://id.foo.bar");
expect(api.opts.idBaseUrl).toBe("https://id.foo.bar");
@@ -82,23 +81,35 @@ describe("FetchHttpApi", () => {
describe("idServerRequest", () => {
it("should throw if no idBaseUrl", () => {
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
expect(() => api.idServerRequest(Method.Get, "/test", {}, IdentityPrefix.V2)).toThrow(
"No identity server base URL set",
);
});
it("should send params as query string for GET requests", () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, idBaseUrl, prefix, fetchFn });
const fetchFn = makeMockFetchFn();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
idBaseUrl,
prefix,
fetchFn,
onlyData: true,
});
api.idServerRequest(Method.Get, "/test", { foo: "bar", via: ["a", "b"] }, IdentityPrefix.V2);
expect(fetchFn.mock.calls[0][0].searchParams.get("foo")).toBe("bar");
expect(fetchFn.mock.calls[0][0].searchParams.getAll("via")).toEqual(["a", "b"]);
});
it("should send params as body for non-GET requests", () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, idBaseUrl, prefix, fetchFn });
const fetchFn = makeMockFetchFn();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
idBaseUrl,
prefix,
fetchFn,
onlyData: true,
});
const params = { foo: "bar", via: ["a", "b"] };
api.idServerRequest(Method.Post, "/test", params, IdentityPrefix.V2);
expect(fetchFn.mock.calls[0][0].searchParams.get("foo")).not.toBe("bar");
@@ -106,18 +117,27 @@ describe("FetchHttpApi", () => {
});
it("should add Authorization header if token provided", () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, idBaseUrl, prefix, fetchFn });
const fetchFn = makeMockFetchFn();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
idBaseUrl,
prefix,
fetchFn,
onlyData: true,
});
api.idServerRequest(Method.Post, "/test", {}, IdentityPrefix.V2, "token");
expect(fetchFn.mock.calls[0][1].headers.Authorization).toBe("Bearer token");
});
});
it("should return the Response object if onlyData=false", async () => {
const res = { ok: true };
const fetchFn = jest.fn().mockResolvedValue(res);
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: false });
await expect(api.requestOtherUrl(Method.Get, "http://url")).resolves.toBe(res);
it("should complain if constructed without `onlyData: true`", async () => {
expect(
() =>
new FetchHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
prefix,
}),
).toThrow("Constructing FetchHttpApi without `onlyData=true` is no longer supported.");
});
it("should set an Accept header, and parse the response as JSON, by default", async () => {
@@ -165,37 +185,40 @@ describe("FetchHttpApi", () => {
});
it("should send token via query params if useAuthorizationHeader=false", async () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const fetchFn = makeMockFetchFn();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
prefix,
fetchFn,
accessToken: "token",
useAuthorizationHeader: false,
onlyData: true,
});
await api.authedRequest(Method.Get, "/path");
expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBe("token");
});
it("should send token via headers by default", async () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const fetchFn = makeMockFetchFn();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
prefix,
fetchFn,
accessToken: "token",
onlyData: true,
});
await api.authedRequest(Method.Get, "/path");
expect(fetchFn.mock.calls[0][1].headers["Authorization"]).toBe("Bearer token");
});
it("should not send a token if not calling `authedRequest`", () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const fetchFn = makeMockFetchFn();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
prefix,
fetchFn,
accessToken: "token",
onlyData: true,
});
api.request(Method.Get, "/path");
expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBeFalsy();
@@ -203,13 +226,14 @@ describe("FetchHttpApi", () => {
});
it("should ensure no token is leaked out via query params if sending via headers", async () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const fetchFn = makeMockFetchFn();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
prefix,
fetchFn,
accessToken: "token",
useAuthorizationHeader: true,
onlyData: true,
});
await api.authedRequest(Method.Get, "/path", { access_token: "123" });
expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBeFalsy();
@@ -217,26 +241,28 @@ describe("FetchHttpApi", () => {
});
it("should not override manually specified access token via query params", async () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const fetchFn = makeMockFetchFn();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
prefix,
fetchFn,
accessToken: "token",
useAuthorizationHeader: false,
onlyData: true,
});
await api.authedRequest(Method.Get, "/path", { access_token: "RealToken" });
expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBe("RealToken");
});
it("should not override manually specified access token via header", async () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const fetchFn = makeMockFetchFn();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
prefix,
fetchFn,
accessToken: "token",
useAuthorizationHeader: true,
onlyData: true,
});
await api.authedRequest(Method.Get, "/path", undefined, undefined, {
headers: { Authorization: "Bearer RealToken" },
@@ -245,8 +271,8 @@ describe("FetchHttpApi", () => {
});
it("should not override Accept header", async () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn });
const fetchFn = makeMockFetchFn();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
await api.authedRequest(Method.Get, "/path", undefined, undefined, {
headers: { Accept: "text/html" },
});
@@ -269,7 +295,7 @@ describe("FetchHttpApi", () => {
),
});
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn });
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn, onlyData: true });
await Promise.all([
emitPromise(emitter, HttpApiEvent.NoConsent),
@@ -279,9 +305,9 @@ describe("FetchHttpApi", () => {
describe("authedRequest", () => {
it("should not include token if unset", async () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const fetchFn = makeMockFetchFn();
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn });
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn, onlyData: true });
await api.authedRequest(Method.Post, "/account/password");
expect(fetchFn.mock.calls[0][1].headers.Authorization).toBeUndefined();
});
@@ -310,6 +336,7 @@ describe("FetchHttpApi", () => {
const okayResponse = {
ok: true,
status: 200,
json: jest.fn().mockResolvedValue({ x: 1 }),
};
describe("without a tokenRefreshFunction", () => {
@@ -317,7 +344,14 @@ describe("FetchHttpApi", () => {
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
jest.spyOn(emitter, "emit");
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn, accessToken, refreshToken });
const api = new FetchHttpApi(emitter, {
baseUrl,
prefix,
fetchFn,
accessToken,
refreshToken,
onlyData: true,
});
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
unknownTokenErr,
);
@@ -339,6 +373,7 @@ describe("FetchHttpApi", () => {
tokenRefreshFunction,
accessToken,
refreshToken,
onlyData: true,
});
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
unknownTokenErr,
@@ -360,6 +395,7 @@ describe("FetchHttpApi", () => {
tokenRefreshFunction,
accessToken,
refreshToken,
onlyData: true,
});
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
unknownTokenErr,
@@ -388,11 +424,12 @@ describe("FetchHttpApi", () => {
tokenRefreshFunction,
accessToken,
refreshToken,
onlyData: true,
});
const result = await api.authedRequest(Method.Post, "/account/password", undefined, undefined, {
headers: {},
});
expect(result).toEqual(okayResponse);
expect(result).toEqual({ x: 1 });
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
expect(fetchFn).toHaveBeenCalledTimes(2);
@@ -431,6 +468,7 @@ describe("FetchHttpApi", () => {
tokenRefreshFunction,
accessToken,
refreshToken,
onlyData: true,
});
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
unknownTokenErr,
@@ -486,6 +524,7 @@ describe("FetchHttpApi", () => {
tokenRefreshFunction,
accessToken,
refreshToken,
onlyData: true,
});
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
unknownTokenErr,
@@ -506,7 +545,7 @@ describe("FetchHttpApi", () => {
const makeApi = (thisBaseUrl = baseUrl): FetchHttpApi<any> => {
const fetchFn = jest.fn();
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
return new FetchHttpApi(emitter, { baseUrl: thisBaseUrl, prefix, fetchFn });
return new FetchHttpApi(emitter, { baseUrl: thisBaseUrl, prefix, fetchFn, onlyData: true });
};
type TestParams = {
@@ -559,7 +598,13 @@ describe("FetchHttpApi", () => {
const makeApiWithExtraParams = (extraParams: QueryDict): FetchHttpApi<any> => {
const fetchFn = jest.fn();
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
return new FetchHttpApi(emitter, { baseUrl: localBaseUrl, prefix, fetchFn, extraParams });
return new FetchHttpApi(emitter, {
baseUrl: localBaseUrl,
prefix,
fetchFn,
onlyData: true,
extraParams,
});
};
const userId = "@rsb-tbg:localhost";
@@ -612,7 +657,7 @@ describe("FetchHttpApi", () => {
it("should work when extraParams is undefined", () => {
const fetchFn = jest.fn();
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
const api = new FetchHttpApi(emitter, { baseUrl: localBaseUrl, prefix, fetchFn });
const api = new FetchHttpApi(emitter, { baseUrl: localBaseUrl, prefix, fetchFn, onlyData: true });
const queryParams = { userId: "123" };
const result = api.getUrl("/test", queryParams);
@@ -645,10 +690,11 @@ describe("FetchHttpApi", () => {
prefix,
fetchFn,
logger: mockLogger,
onlyData: true,
});
const prom = api.requestOtherUrl(Method.Get, "https://server:8448/some/path?query=param#fragment");
jest.advanceTimersByTime(1234);
responseResolvers.resolve({ ok: true, status: 200, text: () => Promise.resolve("RESPONSE") } as Response);
responseResolvers.resolve({ ok: true, status: 200, json: () => Promise.resolve("RESPONSE") } as Response);
await prom;
expect(mockLogger.debug).not.toHaveBeenCalledWith("fragment");
expect(mockLogger.debug).not.toHaveBeenCalledWith("query");
@@ -691,6 +737,7 @@ describe("FetchHttpApi", () => {
tokenRefreshFunction,
accessToken: "ACCESS_TOKEN",
refreshToken: "REFRESH_TOKEN",
onlyData: true,
});
const prom1 = api.authedRequest(Method.Get, "/path1");
@@ -746,6 +793,7 @@ describe("FetchHttpApi", () => {
tokenRefreshFunction,
accessToken: "ACCESS_TOKEN",
refreshToken: "REFRESH_TOKEN",
onlyData: true,
});
const prom1 = api.authedRequest(Method.Get, "/path1");
@@ -783,3 +831,7 @@ describe("FetchHttpApi", () => {
expect(api.opts.refreshToken).toBe("NEW_REFRESH_TOKEN");
});
});
function makeMockFetchFn(): MockedFunction<any> {
return jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue({}) });
}
+21 -14
View File
@@ -62,7 +62,7 @@ describe("MatrixHttpApi", () => {
it("should fall back to `fetch` where xhr is unavailable", async () => {
globalThis.XMLHttpRequest = undefined!;
const fetchFn = jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue({}) });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
upload = api.uploadContent({} as File);
await upload;
expect(fetchFn).toHaveBeenCalled();
@@ -70,7 +70,7 @@ describe("MatrixHttpApi", () => {
it("should prefer xhr where available", () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
upload = api.uploadContent({} as File);
expect(fetchFn).not.toHaveBeenCalled();
expect(xhr.open).toHaveBeenCalled();
@@ -82,6 +82,7 @@ describe("MatrixHttpApi", () => {
prefix,
accessToken: "token",
useAuthorizationHeader: false,
onlyData: true,
});
upload = api.uploadContent({} as File);
expect(xhr.open).toHaveBeenCalledWith(
@@ -96,6 +97,7 @@ describe("MatrixHttpApi", () => {
baseUrl,
prefix,
accessToken: "token",
onlyData: true,
});
upload = api.uploadContent({} as File);
expect(xhr.open).toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/v3/upload");
@@ -103,7 +105,7 @@ describe("MatrixHttpApi", () => {
});
it("should include filename by default", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
upload = api.uploadContent({} as File, { name: "name" });
expect(xhr.open).toHaveBeenCalledWith(
Method.Post,
@@ -112,13 +114,13 @@ describe("MatrixHttpApi", () => {
});
it("should allow not sending the filename", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
upload = api.uploadContent({} as File, { name: "name", includeFilename: false });
expect(xhr.open).toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/v3/upload");
});
it("should abort xhr when the upload is aborted", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
upload = api.uploadContent({} as File);
api.cancelUpload(upload);
expect(xhr.abort).toHaveBeenCalled();
@@ -126,7 +128,7 @@ describe("MatrixHttpApi", () => {
});
it("should timeout if no progress in 30s", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
upload = api.uploadContent({} as File);
jest.advanceTimersByTime(25000);
// @ts-ignore
@@ -138,7 +140,7 @@ describe("MatrixHttpApi", () => {
});
it("should call progressHandler", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
const progressHandler = jest.fn();
upload = api.uploadContent({} as File, { progressHandler });
const progressEvent = new Event("progress") as ProgressEvent;
@@ -154,7 +156,7 @@ describe("MatrixHttpApi", () => {
});
it("should error when no response body", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
upload = api.uploadContent({} as File);
xhr.readyState = DONE;
@@ -167,7 +169,7 @@ describe("MatrixHttpApi", () => {
});
it("should error on a 400-code", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
upload = api.uploadContent({} as File);
xhr.readyState = DONE;
@@ -184,7 +186,7 @@ describe("MatrixHttpApi", () => {
});
it("should return response on successful upload", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
upload = api.uploadContent({} as File);
xhr.readyState = DONE;
@@ -198,14 +200,14 @@ describe("MatrixHttpApi", () => {
});
it("should abort xhr when calling `cancelUpload`", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
upload = api.uploadContent({} as File);
expect(api.cancelUpload(upload)).toBeTruthy();
expect(xhr.abort).toHaveBeenCalled();
});
it("should return false when `cancelUpload` is called but unsuccessful", async () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
upload = api.uploadContent({} as File);
xhr.readyState = DONE;
@@ -220,7 +222,7 @@ describe("MatrixHttpApi", () => {
});
it("should return active uploads in `getCurrentUploads`", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
upload = api.uploadContent({} as File);
expect(api.getCurrentUploads().find((u) => u.promise === upload)).toBeTruthy();
api.cancelUpload(upload);
@@ -228,7 +230,12 @@ describe("MatrixHttpApi", () => {
});
it("should return expected object from `getContentUri`", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, accessToken: "token" });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
prefix,
accessToken: "token",
onlyData: true,
});
expect(api.getContentUri()).toMatchSnapshot();
});
});
+23 -18
View File
@@ -2998,6 +2998,8 @@ describe("MatrixClient", function () {
replacedByDynamicPredecessor2,
room2,
];
client.store.getRoom = (roomId: string) =>
client.store.getRooms().find((r) => r.roomId === roomId) || null;
room1.addLiveEvents(
[
roomCreateEvent(room1.roomId, replacedByCreate1.roomId),
@@ -3036,6 +3038,7 @@ describe("MatrixClient", function () {
replacedByDynamicPredecessor2,
};
}
it("Returns an empty list if there are no rooms", () => {
client.store = new StubStore();
client.store.getRooms = () => [];
@@ -3062,6 +3065,8 @@ describe("MatrixClient", function () {
const room2 = new Room("room2", client, "@daryl:alexandria.example.com");
client.store = new StubStore();
client.store.getRooms = () => [room1, replacedRoom1, replacedRoom2, room2];
client.store.getRoom = (roomId: string) =>
client.store.getRooms().find((r) => r.roomId === roomId) || null;
room1.addLiveEvents([roomCreateEvent(room1.roomId, replacedRoom1.roomId)], { addToState: true });
room2.addLiveEvents([roomCreateEvent(room2.roomId, replacedRoom2.roomId)], { addToState: true });
replacedRoom1.addLiveEvents([tombstoneEvent(room1.roomId, replacedRoom1.roomId)], { addToState: true });
@@ -3127,6 +3132,24 @@ describe("MatrixClient", function () {
expect(rooms).toContain(room1);
expect(rooms).toContain(room2);
});
it("should ignore room replacements which are not reciprocated by the predecessor", () => {
const room1 = new Room("room1", client, "@carol:alexandria.example.com");
// Room 2 claims to replace room 1 but room 1 does not agree
const room2 = new Room("replacedRoom1", client, "@daryl:alexandria.example.com");
client.store = new StubStore();
client.store.getRooms = () => [room1, room2];
client.store.getRoom = (roomId: string) =>
client.store.getRooms().find((r) => r.roomId === roomId) || null;
room2.addLiveEvents([roomCreateEvent(room2.roomId, room1.roomId)], { addToState: true });
// When we ask for the visible rooms
const rooms = client.getVisibleRooms();
expect(rooms).toContain(room1);
expect(rooms).toContain(room2);
});
});
describe("getRoomUpgradeHistory", () => {
@@ -3597,24 +3620,6 @@ describe("MatrixClient", function () {
});
});
describe("getAuthIssuer", () => {
it("should use unstable prefix", async () => {
httpLookups = [
{
method: "GET",
path: `/auth_issuer`,
data: {
issuer: "https://issuer/",
},
prefix: "/_matrix/client/unstable/org.matrix.msc2965",
},
];
await expect(client.getAuthIssuer()).resolves.toEqual({ issuer: "https://issuer/" });
expect(httpLookups.length).toEqual(0);
});
});
describe("getAuthMetadata", () => {
beforeEach(() => {
fetchMock.mockReset();
+116 -52
View File
@@ -17,7 +17,7 @@ limitations under the License.
import { encodeBase64, EventType, MatrixClient, type MatrixError, type MatrixEvent, type Room } from "../../../src";
import { KnownMembership } from "../../../src/@types/membership";
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
import { type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
import { Status, type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
import { secureRandomString } from "../../../src/randomstring";
import { makeMockEvent, makeMockRoom, membershipTemplate, makeKey, type MembershipData, mockRoomState } from "./mocks";
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
@@ -26,6 +26,8 @@ const mockFocus = { type: "mock" };
const textEncoder = new TextEncoder();
const callSession = { id: "", application: "m.call" };
describe("MatrixRTCSession", () => {
let client: MatrixClient;
let sess: MatrixRTCSession | undefined;
@@ -49,14 +51,33 @@ describe("MatrixRTCSession", () => {
it("creates a room-scoped session from room state", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess?.memberships.length).toEqual(1);
expect(sess?.memberships[0].callId).toEqual("");
expect(sess?.memberships[0].sessionDescription.id).toEqual("");
expect(sess?.memberships[0].scope).toEqual("m.room");
expect(sess?.memberships[0].application).toEqual("m.call");
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
expect(sess?.memberships[0].isExpired()).toEqual(false);
expect(sess?.callId).toEqual("");
expect(sess?.sessionDescription.id).toEqual("");
});
it("ignores memberships where application is not m.call", () => {
const testMembership = Object.assign({}, membershipTemplate, {
application: "not-m.call",
});
const mockRoom = makeMockRoom([testMembership]);
const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess?.memberships).toHaveLength(0);
});
it("ignores memberships where callId is not empty", () => {
const testMembership = Object.assign({}, membershipTemplate, {
call_id: "not-empty",
scope: "m.room",
});
const mockRoom = makeMockRoom([testMembership]);
const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess?.memberships).toHaveLength(0);
});
it("ignores expired memberships events", () => {
@@ -67,7 +88,7 @@ describe("MatrixRTCSession", () => {
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership]);
jest.advanceTimersByTime(2000);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess?.memberships.length).toEqual(1);
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
jest.useRealTimers();
@@ -76,7 +97,7 @@ describe("MatrixRTCSession", () => {
it("ignores memberships events of members not in the room", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
mockRoom.hasMembershipState = (state) => state === KnownMembership.Join;
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess?.memberships.length).toEqual(0);
});
@@ -87,14 +108,14 @@ describe("MatrixRTCSession", () => {
expiredMembership.created_ts = 500;
expiredMembership.expires = 1000;
const mockRoom = makeMockRoom([expiredMembership]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500);
jest.useRealTimers();
});
it("returns empty session if no membership events are present", () => {
const mockRoom = makeMockRoom([]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess?.memberships).toHaveLength(0);
});
@@ -129,7 +150,7 @@ describe("MatrixRTCSession", () => {
}),
}),
};
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom as unknown as Room);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession);
expect(sess.memberships).toHaveLength(0);
});
@@ -164,7 +185,7 @@ describe("MatrixRTCSession", () => {
}),
}),
};
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom as unknown as Room);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession);
expect(sess.memberships).toHaveLength(0);
});
@@ -172,7 +193,7 @@ describe("MatrixRTCSession", () => {
const testMembership = Object.assign({}, membershipTemplate);
(testMembership.device_id as string | undefined) = undefined;
const mockRoom = makeMockRoom([testMembership]);
const sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess.memberships).toHaveLength(0);
});
@@ -180,23 +201,7 @@ describe("MatrixRTCSession", () => {
const testMembership = Object.assign({}, membershipTemplate);
(testMembership.call_id as string | undefined) = undefined;
const mockRoom = makeMockRoom([testMembership]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
expect(sess.memberships).toHaveLength(0);
});
it("ignores memberships with no scope", () => {
const testMembership = Object.assign({}, membershipTemplate);
(testMembership.scope as string | undefined) = undefined;
const mockRoom = makeMockRoom([testMembership]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
expect(sess.memberships).toHaveLength(0);
});
it("ignores anything that's not a room-scoped call (for now)", () => {
const testMembership = Object.assign({}, membershipTemplate);
testMembership.scope = "m.user";
const mockRoom = makeMockRoom([testMembership]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess.memberships).toHaveLength(0);
});
});
@@ -211,7 +216,7 @@ describe("MatrixRTCSession", () => {
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess.getOldestMembership()!.deviceId).toEqual("old");
jest.useRealTimers();
});
@@ -236,7 +241,7 @@ describe("MatrixRTCSession", () => {
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], {
type: "livekit",
@@ -256,7 +261,7 @@ describe("MatrixRTCSession", () => {
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], {
type: "livekit",
@@ -283,7 +288,7 @@ describe("MatrixRTCSession", () => {
client._unstable_updateDelayedEvent = jest.fn();
mockRoom = makeMockRoom([]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
});
afterEach(async () => {
@@ -304,8 +309,19 @@ describe("MatrixRTCSession", () => {
expect(sess!.isJoined()).toEqual(true);
});
it("sends a notification when starting a call", async () => {
it("sends a notification when starting a call and emit DidSendCallNotification", async () => {
// Simulate a join, including the update to the room state
// Ensure sendEvent returns event IDs so the DidSendCallNotification payload includes them
sendEventMock
.mockResolvedValueOnce({ event_id: "legacy-evt" })
.mockResolvedValueOnce({ event_id: "new-evt" });
const didSendEventFn = jest.fn();
sess!.once(MatrixRTCSessionEvent.DidSendCallNotification, didSendEventFn);
// Create an additional listener to create a promise that resolves after the emission.
const didSendNotification = new Promise((resolve) => {
sess!.once(MatrixRTCSessionEvent.DidSendCallNotification, resolve);
});
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" });
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
mockRoomState(mockRoom, [{ ...membershipTemplate, user_id: client.getUserId()! }]);
@@ -317,7 +333,7 @@ describe("MatrixRTCSession", () => {
"notification_type": "ring",
"m.relates_to": {
event_id: ownMembershipId,
rel_type: "org.matrix.msc4075.rtc.notification.parent",
rel_type: "m.reference",
},
"lifetime": 30000,
"sender_ts": expect.any(Number),
@@ -330,6 +346,28 @@ describe("MatrixRTCSession", () => {
"notify_type": "ring",
"call_id": "",
});
await didSendNotification;
// And ensure we emitted the DidSendCallNotification event with both payloads
expect(didSendEventFn).toHaveBeenCalledWith(
{
"event_id": "new-evt",
"lifetime": 30000,
"m.mentions": { room: true, user_ids: [] },
"m.relates_to": {
event_id: expect.any(String),
rel_type: "m.reference",
},
"notification_type": "ring",
"sender_ts": expect.any(Number),
},
{
"application": "m.call",
"call_id": "",
"event_id": "legacy-evt",
"m.mentions": { room: true, user_ids: [] },
"notify_type": "ring",
},
);
});
it("doesn't send a notification when joining an existing call", async () => {
@@ -366,7 +404,7 @@ describe("MatrixRTCSession", () => {
describe("onMembershipsChanged", () => {
it("does not emit if no membership changes", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
const onMembershipsChanged = jest.fn();
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
@@ -377,7 +415,7 @@ describe("MatrixRTCSession", () => {
it("emits on membership changes", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
const onMembershipsChanged = jest.fn();
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
@@ -432,7 +470,7 @@ describe("MatrixRTCSession", () => {
client.encryptAndSendToDevice = sendToDeviceMock;
mockRoom = makeMockRoom([]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
});
afterEach(async () => {
@@ -551,7 +589,7 @@ describe("MatrixRTCSession", () => {
device_id: "BBBBBBB",
});
const mockRoom = makeMockRoom([membershipTemplate, member2]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
// joining will trigger an initial key send
const keysSentPromise1 = new Promise<EncryptionKeysEventContent>((resolve) => {
@@ -600,7 +638,7 @@ describe("MatrixRTCSession", () => {
jest.useFakeTimers();
try {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
const keysSentPromise1 = new Promise<EncryptionKeysEventContent>((resolve) => {
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
@@ -651,7 +689,7 @@ describe("MatrixRTCSession", () => {
const mockRoom = makeMockRoom([member1, member2]);
mockRoomState(mockRoom, [member1, member2]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
await keysSentPromise1;
@@ -696,7 +734,7 @@ describe("MatrixRTCSession", () => {
};
const mockRoom = makeMockRoom([member1, member2]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
await keysSentPromise1;
@@ -760,7 +798,7 @@ describe("MatrixRTCSession", () => {
device_id: "BBBBBBB",
});
const mockRoom = makeMockRoom([membershipTemplate, member2]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
const onMyEncryptionKeyChanged = jest.fn();
sess.on(
@@ -850,7 +888,7 @@ describe("MatrixRTCSession", () => {
if (i === 0) {
// if first time around then set up the session
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
} else {
// otherwise update the state reducing the membership each time in order to trigger key rotation
@@ -876,7 +914,7 @@ describe("MatrixRTCSession", () => {
jest.useFakeTimers();
try {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
const keysSentPromise1 = new Promise((resolve) => {
sendEventMock.mockImplementation(resolve);
@@ -917,7 +955,7 @@ describe("MatrixRTCSession", () => {
});
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess!.joinRoomSession([mockFocus], mockFocus, {
manageMediaKeys: true,
@@ -940,7 +978,7 @@ describe("MatrixRTCSession", () => {
describe("receiving", () => {
it("collects keys from encryption events", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
@@ -965,7 +1003,7 @@ describe("MatrixRTCSession", () => {
it("collects keys at non-zero indices", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
@@ -991,7 +1029,7 @@ describe("MatrixRTCSession", () => {
it("collects keys by merging", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
@@ -1042,7 +1080,7 @@ describe("MatrixRTCSession", () => {
it("ignores older keys at same index", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
makeMockEvent(
@@ -1101,7 +1139,7 @@ describe("MatrixRTCSession", () => {
it("key timestamps are treated as monotonic", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
makeMockEvent(
@@ -1145,7 +1183,7 @@ describe("MatrixRTCSession", () => {
it("ignores keys event for the local participant", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
@@ -1168,7 +1206,7 @@ describe("MatrixRTCSession", () => {
jest.useFakeTimers();
try {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
// defaults to getTs()
jest.setSystemTime(1000);
@@ -1221,5 +1259,31 @@ describe("MatrixRTCSession", () => {
}
});
});
describe("read status", () => {
it("returns the correct probablyLeft status", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess!.probablyLeft).toBe(undefined);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
expect(sess!.probablyLeft).toBe(false);
// Simulate the membership manager believing the user has left
const accessPrivateFieldsSession = sess as unknown as {
membershipManager: { state: { probablyLeft: boolean } };
};
accessPrivateFieldsSession.membershipManager.state.probablyLeft = true;
expect(sess!.probablyLeft).toBe(true);
});
it("returns membershipStatus once joinRoomSession got called", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess!.membershipStatus).toBe(undefined);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
expect(sess!.membershipStatus).toBe(Status.Connecting);
});
});
});
});
@@ -16,8 +16,9 @@ limitations under the License.
import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
import { RoomStateEvent } from "../../../src/models/room-state";
import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
import { makeMockRoom, membershipTemplate, mockRoomState } from "./mocks";
import { logger } from "../../../src/logger";
describe("MatrixRTCSessionManager", () => {
let client: MatrixClient;
@@ -47,6 +48,21 @@ describe("MatrixRTCSessionManager", () => {
}
});
it("Doesn't fire event if unrelated sessions starts", () => {
const onStarted = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
try {
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]);
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
expect(onStarted).not.toHaveBeenCalled();
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
}
});
it("Fires event when session ends", () => {
const onEnded = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
@@ -59,9 +75,75 @@ describe("MatrixRTCSessionManager", () => {
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const membEvent = roomState.getStateEvents("")[0];
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
});
it("Fires correctly with for with custom sessionDescription", () => {
const onStarted = jest.fn();
const onEnded = jest.fn();
// create a session manager with a custom session description
const sessionManager = new MatrixRTCSessionManager(logger, client, { id: "test", application: "m.notCall" });
// manually start the session manager (its not the default one started by the client)
sessionManager.start();
sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
try {
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]);
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
expect(onStarted).not.toHaveBeenCalled();
onStarted.mockClear();
const room2 = makeMockRoom([{ ...membershipTemplate, application: "m.notCall", call_id: "test" }]);
jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]);
client.emit(ClientEvent.Room, room2);
expect(onStarted).toHaveBeenCalled();
onStarted.mockClear();
mockRoomState(room2, [{ user_id: membershipTemplate.user_id }]);
jest.spyOn(client, "getRoom").mockReturnValue(room2);
const roomState = room2.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
expect(onEnded).toHaveBeenCalled();
onEnded.mockClear();
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
jest.spyOn(client, "getRoom").mockReturnValue(room1);
const roomStateOther = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const membEventOther = roomStateOther.getStateEvents("org.matrix.msc3401.call.member")[0];
client.emit(RoomStateEvent.Events, membEventOther, roomStateOther, null);
expect(onEnded).not.toHaveBeenCalled();
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
}
});
it("Doesn't fire event if unrelated sessions ends", () => {
const onEnded = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other_app" }]);
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
jest.spyOn(client, "getRoom").mockReturnValue(room1);
client.emit(ClientEvent.Room, room1);
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
});
});
+115 -36
View File
@@ -16,7 +16,14 @@ limitations under the License.
import { type MockedFunction, type Mock } from "jest-mock";
import { EventType, HTTPError, MatrixError, UnsupportedDelayedEventsEndpointError, type Room } from "../../../src";
import {
type EmptyObject,
EventType,
HTTPError,
MatrixError,
UnsupportedDelayedEventsEndpointError,
type Room,
} from "../../../src";
import {
MembershipManagerEvent,
Status,
@@ -64,6 +71,8 @@ function createAsyncHandle<T>(method: MockedFunction<any>) {
return { reject, resolve };
}
const callSession = { id: "", application: "m.call" };
describe("MembershipManager", () => {
let client: MockClient;
let room: Room;
@@ -95,12 +104,12 @@ describe("MembershipManager", () => {
describe("isActivated()", () => {
it("defaults to false", () => {
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
expect(manager.isActivated()).toEqual(false);
});
it("returns true after join()", () => {
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([]);
expect(manager.isActivated()).toEqual(true);
});
@@ -114,7 +123,7 @@ describe("MembershipManager", () => {
const updateDelayedEventHandle = createAsyncHandle<void>(client._unstable_updateDelayedEvent as Mock);
// Test
const memberManager = new MembershipManager(undefined, room, client, () => undefined);
const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession);
memberManager.join([focus], focusActive);
// expects
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
@@ -130,7 +139,7 @@ describe("MembershipManager", () => {
focus_active: focusActive,
scope: "m.room",
},
"_@alice:example.org_AAAAAAA",
"_@alice:example.org_AAAAAAA_m.call",
);
updateDelayedEventHandle.resolve?.();
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
@@ -138,13 +147,13 @@ describe("MembershipManager", () => {
{ delay: 8000 },
"org.matrix.msc3401.call.member",
{},
"_@alice:example.org_AAAAAAA",
"_@alice:example.org_AAAAAAA_m.call",
);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
});
it("reschedules delayed leave event if sending state cancels it", async () => {
const memberManager = new MembershipManager(undefined, room, client, () => undefined);
const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession);
const waitForSendState = waitForMockCall(client.sendStateEvent);
const waitForUpdateDelaye = waitForMockCallOnce(
client._unstable_updateDelayedEvent,
@@ -189,7 +198,7 @@ describe("MembershipManager", () => {
});
});
const userStateKey = `${!useOwnedStateEvents ? "_" : ""}@alice:example.org_AAAAAAA`;
const userStateKey = `${!useOwnedStateEvents ? "_" : ""}@alice:example.org_AAAAAAA_m.call`;
// preparing the delayed disconnect should handle ratelimiting
const sendDelayedStateAttempt = new Promise<void>((resolve) => {
const error = new MatrixError({ errcode: "M_LIMIT_EXCEEDED" });
@@ -220,6 +229,7 @@ describe("MembershipManager", () => {
room,
client,
() => undefined,
callSession,
);
manager.join([focus], focusActive);
@@ -276,7 +286,7 @@ describe("MembershipManager", () => {
describe("delayed leave event", () => {
it("does not try again to schedule a delayed leave event if not supported", () => {
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
delayedHandle.reject?.(
new UnsupportedDelayedEventsEndpointError(
@@ -288,7 +298,7 @@ describe("MembershipManager", () => {
});
it("does try to schedule a delayed leave event again if rate limited", async () => {
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined));
await jest.advanceTimersByTimeAsync(5000);
@@ -300,6 +310,7 @@ describe("MembershipManager", () => {
room,
client,
() => undefined,
callSession,
);
manager.join([focus], focusActive);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
@@ -307,7 +318,7 @@ describe("MembershipManager", () => {
{ delay: 123456 },
"org.matrix.msc3401.call.member",
{},
"_@alice:example.org_AAAAAAA",
"_@alice:example.org_AAAAAAA_m.call",
);
});
});
@@ -319,6 +330,7 @@ describe("MembershipManager", () => {
room,
client,
() => undefined,
callSession,
);
// Join with the membership manager
manager.join([focus], focusActive);
@@ -351,7 +363,13 @@ describe("MembershipManager", () => {
});
it("uses membershipEventExpiryMs from config", async () => {
const manager = new MembershipManager({ membershipEventExpiryMs: 1234567 }, room, client, () => undefined);
const manager = new MembershipManager(
{ membershipEventExpiryMs: 1234567 },
room,
client,
() => undefined,
callSession,
);
manager.join([focus], focusActive);
await waitForMockCall(client.sendStateEvent);
@@ -370,12 +388,12 @@ describe("MembershipManager", () => {
type: "livekit",
},
},
"_@alice:example.org_AAAAAAA",
"_@alice:example.org_AAAAAAA_m.call",
);
});
it("does nothing if join called when already joined", async () => {
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
await waitForMockCall(client.sendStateEvent);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
@@ -387,7 +405,7 @@ describe("MembershipManager", () => {
describe("leave()", () => {
// TODO add rate limit cases.
it("resolves delayed leave event when leave is called", async () => {
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
await manager.leave();
@@ -395,7 +413,7 @@ describe("MembershipManager", () => {
expect(client.sendStateEvent).toHaveBeenCalled();
});
it("send leave event when leave is called and resolving delayed leave fails", async () => {
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
@@ -406,11 +424,11 @@ describe("MembershipManager", () => {
room.roomId,
"org.matrix.msc3401.call.member",
{},
"_@alice:example.org_AAAAAAA",
"_@alice:example.org_AAAAAAA_m.call",
);
});
it("does nothing if not joined", () => {
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
expect(async () => await manager.leave()).not.toThrow();
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
expect(client.sendStateEvent).not.toHaveBeenCalled();
@@ -420,7 +438,7 @@ describe("MembershipManager", () => {
describe("getsActiveFocus", () => {
it("gets the correct active focus with oldest_membership", () => {
const getOldestMembership = jest.fn();
const manager = new MembershipManager({}, room, client, getOldestMembership);
const manager = new MembershipManager({}, room, client, getOldestMembership, callSession);
// Before joining the active focus should be undefined (see FocusInUse on MatrixRTCSession)
expect(manager.getActiveFocus()).toBe(undefined);
manager.join([focus], focusActive);
@@ -455,7 +473,7 @@ describe("MembershipManager", () => {
});
it("does not provide focus if the selection method is unknown", () => {
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], Object.assign(focusActive, { type: "unknown_type" }));
expect(manager.getActiveFocus()).toBe(undefined);
});
@@ -463,7 +481,7 @@ describe("MembershipManager", () => {
describe("onRTCSessionMemberUpdate()", () => {
it("does nothing if not joined", async () => {
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
await jest.advanceTimersToNextTimerAsync();
expect(client.sendStateEvent).not.toHaveBeenCalled();
@@ -471,7 +489,7 @@ describe("MembershipManager", () => {
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
});
it("does nothing if own membership still present", async () => {
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2];
@@ -495,7 +513,7 @@ describe("MembershipManager", () => {
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
});
it("recreates membership if it is missing", async () => {
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
@@ -513,7 +531,7 @@ describe("MembershipManager", () => {
});
it("updates the UpdateExpiry entry in the action scheduler", async () => {
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
@@ -547,6 +565,7 @@ describe("MembershipManager", () => {
room,
client,
() => undefined,
{ id: "", application: "m.call" },
);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
@@ -578,6 +597,7 @@ describe("MembershipManager", () => {
room,
client,
() => undefined,
{ id: "", application: "m.call" },
);
manager.join([focus], focusActive);
await waitForMockCall(client.sendStateEvent);
@@ -598,16 +618,17 @@ describe("MembershipManager", () => {
await testExpires(10_000, 1_000);
});
});
describe("status updates", () => {
it("starts 'Disconnected'", () => {
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
expect(manager.status).toBe(Status.Disconnected);
});
it("emits 'Connection' and 'Connected' after join", async () => {
const handleDelayedEvent = createAsyncHandle<void>(client._unstable_sendDelayedStateEvent);
const handleStateEvent = createAsyncHandle<void>(client.sendStateEvent);
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
expect(manager.status).toBe(Status.Disconnected);
const connectEmit = jest.fn();
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
@@ -621,7 +642,7 @@ describe("MembershipManager", () => {
expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected);
});
it("emits 'Disconnecting' and 'Disconnected' after leave", async () => {
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const connectEmit = jest.fn();
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
manager.join([focus], focusActive);
@@ -637,7 +658,7 @@ describe("MembershipManager", () => {
it("sends retry if call membership event is still valid at time of retry", async () => {
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
@@ -664,7 +685,7 @@ describe("MembershipManager", () => {
new Headers({ "Retry-After": "1" }),
),
);
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
// Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the
// RateLimit error.
manager.join([focus], focusActive);
@@ -684,7 +705,7 @@ describe("MembershipManager", () => {
it("abandons retry loop if leave() was called before sending state event", async () => {
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
handle.reject?.(
new MatrixError(
@@ -719,7 +740,7 @@ describe("MembershipManager", () => {
new Headers({ "Retry-After": "1" }),
),
);
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
// Hit rate limit
@@ -752,7 +773,7 @@ describe("MembershipManager", () => {
new Headers({ "Retry-After": "2" }),
),
);
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive, delayEventSendError);
for (let i = 0; i < 10; i++) {
@@ -772,7 +793,7 @@ describe("MembershipManager", () => {
new Headers({ "Retry-After": "1" }),
),
);
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive, delayEventRestartError);
for (let i = 0; i < 10; i++) {
@@ -783,7 +804,7 @@ describe("MembershipManager", () => {
it("falls back to using pure state events when some error occurs while sending delayed events", async () => {
const unrecoverableError = jest.fn();
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 601));
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive, unrecoverableError);
await waitForMockCall(client.sendStateEvent);
expect(unrecoverableError).not.toHaveBeenCalledWith();
@@ -797,6 +818,7 @@ describe("MembershipManager", () => {
room,
client,
() => undefined,
callSession,
);
manager.join([focus], focusActive, unrecoverableError);
for (let retries = 0; retries < 7; retries++) {
@@ -814,7 +836,7 @@ describe("MembershipManager", () => {
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"),
);
const manager = new MembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive, unrecoverableError);
await jest.advanceTimersByTimeAsync(1);
@@ -822,13 +844,70 @@ describe("MembershipManager", () => {
expect(client.sendStateEvent).toHaveBeenCalled();
});
});
describe("probablyLeft", () => {
it("emits probablyLeft when the membership manager could not hear back from the server for the duration of the delayed event", async () => {
const manager = new MembershipManager(
{ delayedLeaveEventDelayMs: 10000 },
room,
client,
() => undefined,
callSession,
);
const { promise: stuckPromise, reject: rejectStuckPromise } = Promise.withResolvers<EmptyObject>();
const probablyLeftEmit = jest.fn();
manager.on(MembershipManagerEvent.ProbablyLeft, probablyLeftEmit);
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);
// 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);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
expect(manager.status).toBe(Status.Connected);
expect(probablyLeftEmit).not.toHaveBeenCalledWith(true);
// We expect the probablyLeft event to be emitted after the `delayedLeaveEventDelayMs` = 10000.
// We also track the calls to updated the delayed event that all will never resolve to simulate the server not responding.
// The numbers are a bit arbitrary since we use the local timeout that does not perfectly match the 5s check interval in this test.
await jest.advanceTimersByTimeAsync(5000);
// No emission after 5s
expect(probablyLeftEmit).not.toHaveBeenCalledWith(true);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(4999);
expect(client._unstable_updateDelayedEvent).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({});
// Emit after 10s
await jest.advanceTimersByTimeAsync(1);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(4);
expect(probablyLeftEmit).toHaveBeenCalledWith(true);
// Mock a sync which does not include our own membership
await manager.onRTCSessionMemberUpdate([]);
// Wait for the current ongoing delayed event sending to finish
await jest.advanceTimersByTimeAsync(1);
// We should send a new state event and an associated delayed leave event.
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
expect(client.sendStateEvent).toHaveBeenCalledTimes(2);
// At the same time we expect the probablyLeft event to be emitted with false so we are back operational.
expect(probablyLeftEmit).toHaveBeenCalledWith(false);
} finally {
rejectStuckPromise();
}
});
});
});
it("Should prefix log with MembershipManager used", () => {
const client = makeMockClient("@alice:example.org", "AAAAAAA");
const room = makeMockRoom([membershipTemplate]);
const membershipManager = new MembershipManager(undefined, room, client, () => undefined, logger);
const membershipManager = new MembershipManager(undefined, room, client, () => undefined, callSession, logger);
const spy = jest.spyOn(console, "error");
// Double join
+9 -3
View File
@@ -379,7 +379,9 @@ describe("MatrixEvent", () => {
expect(encryptedEvent.isBeingDecrypted()).toBeFalsy();
expect(encryptedEvent.isDecryptionFailure()).toBeTruthy();
expect(encryptedEvent.decryptionFailureReason).toEqual(DecryptionFailureCode.UNKNOWN_ERROR);
expect(encryptedEvent.isEncryptedDisabledForUnverifiedDevices).toBeFalsy();
expect(encryptedEvent.decryptionFailureReason).not.toBe(
DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE,
);
expect(encryptedEvent.getContent()).toEqual({
msgtype: "m.bad.encrypted",
body: "** Unable to decrypt: Error: test error **",
@@ -403,7 +405,9 @@ describe("MatrixEvent", () => {
expect(encryptedEvent.decryptionFailureReason).toEqual(
DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
);
expect(encryptedEvent.isEncryptedDisabledForUnverifiedDevices).toBeFalsy();
expect(encryptedEvent.decryptionFailureReason).not.toBe(
DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE,
);
expect(encryptedEvent.getContent()).toEqual({
msgtype: "m.bad.encrypted",
body: "** Unable to decrypt: DecryptionError: uisi **",
@@ -427,7 +431,9 @@ describe("MatrixEvent", () => {
expect(encryptedEvent.isEncrypted()).toBeTruthy();
expect(encryptedEvent.isBeingDecrypted()).toBeFalsy();
expect(encryptedEvent.isDecryptionFailure()).toBeTruthy();
expect(encryptedEvent.isEncryptedDisabledForUnverifiedDevices).toBeTruthy();
expect(encryptedEvent.decryptionFailureReason).toBe(
DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE,
);
expect(encryptedEvent.getContent()).toEqual({
msgtype: "m.bad.encrypted",
body: "** Unable to decrypt: DecryptionError: The sender has disabled encrypting to unverified devices. **",
+210 -1
View File
@@ -22,7 +22,7 @@ import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../..
import { makeThreadEvent, mkThread, populateThread } from "../../test-utils/thread";
import { TestClient } from "../../TestClient";
import { emitPromise, mkEdit, mkMessage, mkReaction, mock } from "../../test-utils/test-utils";
import { Direction, EventStatus, EventType, MatrixEvent } from "../../../src";
import { Direction, EventStatus, EventType, MatrixEvent, RelationType } from "../../../src";
import { ReceiptType } from "../../../src/@types/read_receipts";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils/client";
import { ReEmitter } from "../../../src/ReEmitter";
@@ -773,6 +773,215 @@ describe("Thread", () => {
expect(lastEvent).toBe(message1);
expect(firstEvent).toBe(message2);
});
it("Edit events are properly aggregated in threads with server-side support", async () => {
// This test reproduces the race condition bug from https://github.com/element-hq/element-web/issues/30617
// The bug occurs when edits arrive while the thread is not initialized,
// causing aggregation to fail because the target event isn't in the timeline yet
// Given a thread exists with server-side support enabled
const myUserId = "@alice:example.org";
const testClient = new TestClient(myUserId, "DEVICE", "ACCESS_TOKEN", undefined, {
timelineSupport: false,
});
const client = testClient.client;
client.supportsThreads = jest.fn().mockReturnValue(true);
const room = new Room("!room:z", client, myUserId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
jest.spyOn(client, "getRoom").mockReturnValue(room);
// Create a root event
const rootEvent = mkMessage({
room: room.roomId,
user: myUserId,
msg: "Root message",
event: true,
});
// Create thread manually - starts with initialEventsFetched = false
const thread = new Thread(rootEvent.getId()!, rootEvent, {
room: room,
client: client,
pendingEventOrdering: PendingEventOrdering.Detached,
});
// The thread is NOT initialized - this is the key to reproducing the bug!
expect(thread.initialEventsFetched).toBe(false);
// Create a message that will be edited
const originalMessage = mkMessage({
room: room.roomId,
user: myUserId,
msg: "Original message in thread",
relatesTo: {
"rel_type": THREAD_RELATION_TYPE.name,
"event_id": thread.id,
"m.in_reply_to": {
event_id: thread.id,
},
},
event: true,
});
// Create edit events BEFORE the original message is in the timeline
const edit1 = mkEdit(originalMessage, client, myUserId, room.roomId, "Edit 1");
const edit2 = mkEdit(originalMessage, client, myUserId, room.roomId, "Edit 2");
const edit3 = mkEdit(originalMessage, client, myUserId, room.roomId, "Final edit");
// CRITICAL: Add edits while thread is NOT initialized
// They will be queued in replayEvents and aggregation will be attempted but fail
await thread.addEvent(edit1, false);
// Check the aggregation state after adding first edit
// With our fix: edits should NOT be aggregated yet (thread not initialized)
// Without fix: edits would be aggregated but fail to link to target
const relationsAfterFirstEdit = thread.timelineSet.relations?.getChildEventsForEvent(
originalMessage.getId()!,
RelationType.Replace,
EventType.RoomMessage,
);
// With the fix, no aggregation happens yet (which is correct)
// Without the fix, aggregation would happen but fail silently
expect(relationsAfterFirstEdit).toBeUndefined();
// Add remaining edits
await thread.addEvent(edit2, false);
await thread.addEvent(edit3, false);
// Check that edits went to replayEvents
expect(thread.replayEvents).toHaveLength(3);
// Now initialize the thread and add the original message
thread.initialEventsFetched = true;
// Clear replayEvents and add the original message
const replayEvents = [...(thread.replayEvents || [])];
thread.replayEvents = [];
// Add original message first
await thread.addEvent(originalMessage, false);
// At this point, the original message should NOT have the edits aggregated yet
// because they were attempted when the target wasn't in timeline
const replacingEventBeforeReplay = originalMessage.replacingEvent();
// With the fix, edits should not be aggregated yet (pre-init)
expect(replacingEventBeforeReplay).toBeNull();
// Then replay the edits
for (const event of replayEvents) {
await thread.addEvent(event, false);
}
// After replay, check aggregation
const replacingEvent = originalMessage.replacingEvent();
// This should now work because edits were re-aggregated when replayed
expect(replacingEvent).toBe(edit3);
// The content should also be updated
expect(originalMessage.getContent().body).toBe("Final edit");
// Relations for replaces should now exist and include all edits in order
const replaceRels = thread.timelineSet.relations!.getChildEventsForEvent(
originalMessage.getId()!,
RelationType.Replace,
EventType.RoomMessage,
)!;
const replaceIds = replaceRels.getRelations().map((e) => e.getId());
expect(replaceIds).toHaveLength(3);
expect(replaceIds[0]).toBe(edit1.getId());
expect(replaceIds[1]).toBe(edit2.getId());
expect(replaceIds[2]).toBe(edit3.getId());
});
it("Reactions aggregate pre-init and remain idempotent on replay", async () => {
const myUserId = "@alice:example.org";
const testClient = new TestClient(myUserId, "DEVICE", "ACCESS_TOKEN", undefined, {
timelineSupport: false,
});
const client = testClient.client;
client.supportsThreads = jest.fn().mockReturnValue(true);
// Force server-side support so threads start uninitialised
const prevSupport = Thread.hasServerSideSupport;
Thread.setServerSideSupport(FeatureSupport.Stable);
try {
const room = new Room("!room:z", client, myUserId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
jest.spyOn(client, "getRoom").mockReturnValue(room);
// Create a root event and thread
const rootEvent = mkMessage({ room: room.roomId, user: myUserId, msg: "Root", event: true });
const thread = new Thread(rootEvent.getId()!, rootEvent, {
room,
client,
pendingEventOrdering: PendingEventOrdering.Detached,
});
expect(thread.initialEventsFetched).toBe(false);
// A message inside the thread to react to
const originalMessage = mkMessage({
room: room.roomId,
user: myUserId,
msg: "Thread message",
relatesTo: {
"rel_type": THREAD_RELATION_TYPE.name,
"event_id": thread.id,
"m.in_reply_to": { event_id: thread.id },
},
event: true,
});
// Create 2 reactions before the message is in the timeline (pre-init)
const reaction1 = mkReaction(originalMessage, client, myUserId, room.roomId);
const reaction2 = mkReaction(originalMessage, client, myUserId, room.roomId);
// Add reactions while thread is NOT initialised
thread.addEvent(reaction1, false);
thread.addEvent(reaction2, false);
// Relations should already include the reactions pre-init
const relsBefore = thread.timelineSet.relations!.getChildEventsForEvent(
originalMessage.getId()!,
RelationType.Annotation,
EventType.Reaction,
)!;
expect(relsBefore).toBeTruthy();
const beforeIds = new Set(relsBefore.getRelations().map((e) => e.getId()));
expect(beforeIds.size).toBe(2);
// Now initialise and replay
// Ensure reactions are queued for replay as well
expect(thread.replayEvents).toHaveLength(2);
const replay = [...(thread.replayEvents || [])];
thread.replayEvents = [];
thread.initialEventsFetched = true;
// Add the original message first so it becomes findable
thread.addEvent(originalMessage, false);
// Replay reactions
for (const ev of replay) thread.addEvent(ev, false);
// Ensure no duplicates after replay (idempotent aggregation)
const relsAfter = thread.timelineSet.relations!.getChildEventsForEvent(
originalMessage.getId()!,
RelationType.Annotation,
EventType.Reaction,
)!;
const afterIds = new Set(relsAfter.getRelations().map((e) => e.getId()));
expect(afterIds.size).toBe(2);
expect(afterIds).toEqual(beforeIds);
} finally {
// restore
Thread.setServerSideSupport(prevSupport);
}
});
});
});
});
+17
View File
@@ -164,6 +164,23 @@ describe("oidc authorization", () => {
expect(authUrl.searchParams.get("prompt")).toEqual("create");
});
it("should generate url with login_hint", async () => {
const nonce = "abc123";
const authUrl = new URL(
await generateOidcAuthorizationUrl({
metadata: delegatedAuthConfig,
homeserverUrl: baseUrl,
clientId,
redirectUri: baseUrl,
nonce,
loginHint: "login1234",
}),
);
expect(authUrl.searchParams.get("login_hint")).toEqual("login1234");
});
});
describe("completeAuthorizationCodeGrant", () => {
+20 -146
View File
@@ -20,8 +20,8 @@ import * as utils from "../test-utils/test-utils";
import { RoomMember, RoomMemberEvent } from "../../src/models/room-member";
import {
createClient,
EventType,
type MatrixClient,
MatrixEvent,
type RoomState,
UNSTABLE_MSC2666_MUTUAL_ROOMS,
UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS,
@@ -101,158 +101,32 @@ describe("RoomMember", function () {
});
});
describe("setPowerLevelEvent", function () {
it("should set 'powerLevel' and 'powerLevelNorm'.", function () {
const event = utils.mkEvent({
type: "m.room.power_levels",
room: roomId,
user: userA,
content: {
users_default: 20,
users: {
"@bertha:bar": 200,
"@invalid:user": 10, // shouldn't barf on this.
},
},
event: true,
});
member.setPowerLevelEvent(event);
expect(member.powerLevel).toEqual(20);
expect(member.powerLevelNorm).toEqual(10);
const memberB = new RoomMember(roomId, userB);
memberB.setPowerLevelEvent(event);
expect(memberB.powerLevel).toEqual(200);
expect(memberB.powerLevelNorm).toEqual(100);
});
it("should emit 'RoomMember.powerLevel' if the power level changes.", function () {
const event = utils.mkEvent({
type: "m.room.power_levels",
room: roomId,
user: userA,
content: {
users_default: 20,
users: {
"@bertha:bar": 200,
"@invalid:user": 10, // shouldn't barf on this.
},
},
event: true,
});
let emitCount = 0;
member.on(RoomMemberEvent.PowerLevel, function (emitEvent, emitMember) {
emitCount += 1;
expect(emitMember).toEqual(member);
expect(emitEvent).toEqual(event);
});
member.setPowerLevelEvent(event);
expect(emitCount).toEqual(1);
member.setPowerLevelEvent(event); // no-op
expect(emitCount).toEqual(1);
});
it("should honour power levels of zero.", function () {
const event = utils.mkEvent({
type: "m.room.power_levels",
room: roomId,
user: userA,
content: {
users_default: 20,
users: {
"@alice:bar": 0,
},
},
event: true,
});
let emitCount = 0;
// set the power level to something other than zero or we
// won't get an event
member.powerLevel = 1;
member.on(RoomMemberEvent.PowerLevel, function (emitEvent, emitMember) {
emitCount += 1;
expect(emitMember.userId).toEqual("@alice:bar");
expect(emitMember.powerLevel).toEqual(0);
expect(emitEvent).toEqual(event);
});
member.setPowerLevelEvent(event);
describe("setPowerLevel", function () {
it("should set 'powerLevel'.", function () {
member.setPowerLevel(0, new MatrixEvent());
expect(member.powerLevel).toEqual(0);
expect(emitCount).toEqual(1);
member.setPowerLevel(200, new MatrixEvent());
expect(member.powerLevel).toEqual(200);
});
it("should not honor string power levels.", function () {
const event = utils.mkEvent({
type: "m.room.power_levels",
room: roomId,
user: userA,
content: {
users_default: 20,
users: {
"@alice:bar": "5",
},
},
event: true,
});
let emitCount = 0;
it("should emit when power level set", function () {
const onEmit = jest.fn();
member.on(RoomMemberEvent.PowerLevel, onEmit);
member.on(RoomMemberEvent.PowerLevel, function (emitEvent, emitMember) {
emitCount += 1;
expect(emitMember.userId).toEqual("@alice:bar");
expect(emitMember.powerLevel).toEqual(20);
expect(emitEvent).toEqual(event);
});
const aMatrixEvent = new MatrixEvent();
member.setPowerLevel(10, aMatrixEvent);
member.setPowerLevelEvent(event);
expect(member.powerLevel).toEqual(20);
expect(emitCount).toEqual(1);
expect(onEmit).toHaveBeenCalledWith(aMatrixEvent, member);
});
it("should no-op if given a non-state or unrelated event", () => {
const fn = jest.spyOn(member, "emit");
expect(fn).not.toHaveBeenCalledWith(RoomMemberEvent.PowerLevel);
member.setPowerLevelEvent(
utils.mkEvent({
type: EventType.RoomPowerLevels,
room: roomId,
user: userA,
content: {
users_default: 20,
users: {
"@alice:bar": "5",
},
},
skey: "invalid",
event: true,
}),
);
const nonStateEv = utils.mkEvent({
type: EventType.RoomPowerLevels,
room: roomId,
user: userA,
content: {
users_default: 20,
users: {
"@alice:bar": "5",
},
},
event: true,
});
delete nonStateEv.event.state_key;
member.setPowerLevelEvent(nonStateEv);
member.setPowerLevelEvent(
utils.mkEvent({
type: EventType.Sticker,
room: roomId,
user: userA,
content: {},
event: true,
}),
);
expect(fn).not.toHaveBeenCalledWith(RoomMemberEvent.PowerLevel);
it("should not emit if new power level is the same", function () {
const onEmit = jest.fn();
member.on(RoomMemberEvent.PowerLevel, onEmit);
const aMatrixEvent = new MatrixEvent();
member.setPowerLevel(0, aMatrixEvent);
expect(onEmit).not.toHaveBeenCalled();
});
});
+194 -7
View File
@@ -20,6 +20,7 @@ import * as utils from "../test-utils/test-utils";
import { makeBeaconEvent, makeBeaconInfoEvent } from "../test-utils/beacon";
import { filterEmitCallsByEventType } from "../test-utils/emitter";
import { RoomState, RoomStateEvent } from "../../src/models/room-state";
import { RoomMemberEvent } from "../../src/models/room-member";
import { type Beacon, BeaconEvent, getBeaconInfoIdentifier } from "../../src/models/beacon";
import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@types/event";
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
@@ -38,10 +39,10 @@ describe("RoomState", function () {
const userLazy = "@lazy:bar";
let state = new RoomState(roomId);
let statev12 = new RoomState(roomId);
beforeEach(function () {
state = new RoomState(roomId);
state.setStateEvents([
const commonEvents = [
utils.mkMembership({
// userA joined
event: true,
@@ -66,6 +67,11 @@ describe("RoomState", function () {
name: "Room name goes here",
},
}),
];
state = new RoomState(roomId);
state.setStateEvents([
...commonEvents,
utils.mkEvent({
// Room creation
type: "m.room.create",
@@ -75,6 +81,19 @@ describe("RoomState", function () {
content: {},
}),
]);
statev12 = new RoomState(roomId);
statev12.setStateEvents([
...commonEvents,
utils.mkEvent({
// Room creation (v12 version)
type: "m.room.create",
user: userA,
room: roomId,
event: true,
content: { room_version: "12" },
}),
]);
});
describe("getMembers", function () {
@@ -259,7 +278,7 @@ describe("RoomState", function () {
expect(emitCount).toEqual(2);
});
it("should call setPowerLevelEvent on each RoomMember for m.room.power_levels", function () {
it("should call setPowerLevel on each RoomMember for m.room.power_levels", function () {
const powerLevelEvent = utils.mkEvent({
type: "m.room.power_levels",
room: roomId,
@@ -273,12 +292,12 @@ describe("RoomState", function () {
});
// spy on the room members
jest.spyOn(state.members[userA], "setPowerLevelEvent");
jest.spyOn(state.members[userB], "setPowerLevelEvent");
jest.spyOn(state.members[userA], "setPowerLevel");
jest.spyOn(state.members[userB], "setPowerLevel");
state.setStateEvents([powerLevelEvent]);
expect(state.members[userA].setPowerLevelEvent).toHaveBeenCalledWith(powerLevelEvent);
expect(state.members[userB].setPowerLevelEvent).toHaveBeenCalledWith(powerLevelEvent);
expect(state.members[userA].setPowerLevel).toHaveBeenCalledWith(10, powerLevelEvent);
expect(state.members[userB].setPowerLevel).toHaveBeenCalledWith(10, powerLevelEvent);
});
it("should call setPowerLevelEvent on a new RoomMember if power levels exist", function () {
@@ -310,6 +329,156 @@ describe("RoomState", function () {
expect(state.members[userC].powerLevel).toEqual(10);
});
it("should calculate power level correctly", function () {
const powerLevelEvent = utils.mkEvent({
type: "m.room.power_levels",
room: roomId,
user: userA,
content: {
users_default: 20,
users: {
[userB]: 200,
"@invalid:user": 10, // shouldn't barf on this.
},
},
event: true,
});
state.setStateEvents([powerLevelEvent]);
expect(state.getMember(userA)?.powerLevel).toEqual(20);
expect(state.getMember(userB)?.powerLevel).toEqual(200);
});
it("should set 'powerLevel' with a v12 room.", function () {
const createEventV12 = utils.mkEvent({
type: "m.room.create",
room: roomId,
sender: userA,
content: { room_version: "12" },
event: true,
});
const powerLevelEvent = utils.mkEvent({
type: "m.room.power_levels",
room: roomId,
user: userA,
content: {
users_default: 20,
users: {
[userB]: 200,
"@invalid:user": 10, // shouldn't barf on this.
},
},
event: true,
});
state.setStateEvents([createEventV12, powerLevelEvent]);
expect(state.getMember(userA)?.powerLevel).toEqual(Infinity);
});
it("should honour power levels of zero.", function () {
const powerLevelEvent = utils.mkEvent({
type: "m.room.power_levels",
room: roomId,
user: userA,
content: {
users_default: 20,
users: {
"@alice:bar": 0,
},
},
event: true,
});
let emitCount = 0;
const memberA = state.getMember(userA)!;
// set the power level to something other than zero or we
// won't get an event
memberA.powerLevel = 1;
memberA.on(RoomMemberEvent.PowerLevel, function (emitEvent, emitMember) {
emitCount += 1;
expect(emitMember.userId).toEqual("@alice:bar");
expect(emitMember.powerLevel).toEqual(0);
expect(emitEvent).toEqual(powerLevelEvent);
});
state.setStateEvents([powerLevelEvent]);
expect(memberA.powerLevel).toEqual(0);
expect(emitCount).toEqual(1);
});
it("should not honor string power levels.", function () {
const powerLevelEvent = utils.mkEvent({
type: "m.room.power_levels",
room: roomId,
user: userA,
content: {
users_default: 20,
users: {
"@alice:bar": "5",
},
},
event: true,
});
let emitCount = 0;
const memberA = state.getMember(userA)!;
memberA.on(RoomMemberEvent.PowerLevel, function (emitEvent, emitMember) {
emitCount += 1;
expect(emitMember.userId).toEqual("@alice:bar");
expect(emitMember.powerLevel).toEqual(20);
expect(emitEvent).toEqual(powerLevelEvent);
});
state.setStateEvents([powerLevelEvent]);
expect(memberA.powerLevel).toEqual(20);
expect(emitCount).toEqual(1);
});
it("should no-op if given a non-state or unrelated event", () => {
const memberA = state.getMember(userA)!;
const fn = jest.spyOn(memberA, "emit");
expect(fn).not.toHaveBeenCalledWith(RoomMemberEvent.PowerLevel);
const powerLevelEvent = utils.mkEvent({
type: EventType.RoomPowerLevels,
room: roomId,
user: userA,
content: {
users_default: 20,
users: {
"@alice:bar": "5",
},
},
skey: "invalid",
event: true,
});
state.setStateEvents([powerLevelEvent]);
const nonStateEv = utils.mkEvent({
type: EventType.RoomPowerLevels,
room: roomId,
user: userA,
content: {
users_default: 20,
users: {
"@alice:bar": "5",
},
},
event: true,
});
delete nonStateEv.event.state_key;
state.setStateEvents([nonStateEv]);
state.setStateEvents([
utils.mkEvent({
type: EventType.Sticker,
room: roomId,
user: userA,
content: {},
event: true,
}),
]);
expect(fn).not.toHaveBeenCalledWith(RoomMemberEvent.PowerLevel);
});
it("should call setMembershipEvent on the right RoomMember", function () {
const memberEvent = utils.mkMembership({
user: userB,
@@ -851,6 +1020,24 @@ describe("RoomState", function () {
expect(state.maySendEvent("m.room.other_thing", userA)).toEqual(true);
expect(state.maySendEvent("m.room.other_thing", userB)).toEqual(false);
});
it("should recognise power level of room creators in v12 rooms", function () {
const powerLevelEvent = new MatrixEvent({
type: "m.room.power_levels",
room_id: roomId,
sender: userA,
state_key: "",
content: {
users_default: 0,
state_default: 100,
events_default: 100,
users: {},
},
});
statev12.setStateEvents([powerLevelEvent]);
expect(statev12.maySendEvent("m.room.name", userA)).toEqual(true);
});
});
describe("processBeaconEvents", () => {
@@ -2353,6 +2353,7 @@ describe("RustCrypto", () => {
beforeEach(async () => {
mockOlmMachine = {
queryKeysForUsers: jest.fn().mockReturnValue({}),
getReceivedRoomKeyBundleData: jest.fn(),
receiveRoomKeyBundle: jest.fn(),
} as unknown as Mocked<OlmMachine>;
@@ -2377,6 +2378,7 @@ describe("RustCrypto", () => {
it("does nothing if there is no key bundle", async () => {
mockOlmMachine.getReceivedRoomKeyBundleData.mockResolvedValue(undefined);
await rustCrypto.maybeAcceptKeyBundle("!room_id", "@bob:example.org");
expect(mockOlmMachine.queryKeysForUsers).toHaveBeenCalledTimes(1);
expect(mockOlmMachine.getReceivedRoomKeyBundleData).toHaveBeenCalledTimes(1);
expect(mockOlmMachine.getReceivedRoomKeyBundleData.mock.calls[0][0].toString()).toEqual("!room_id");
expect(mockOlmMachine.getReceivedRoomKeyBundleData.mock.calls[0][1].toString()).toEqual("@bob:example.org");
@@ -156,6 +156,8 @@ describe("VerificationRequest", () => {
);
const verificationRequestContent = JSON.parse(await bobUserIdentity.verificationRequestContent(methods));
todoFixupVerificationRequestContent(verificationRequestContent);
await bobOlmMachine.receiveVerificationEvent(
JSON.stringify({
type: "m.room.message",
@@ -289,6 +291,8 @@ describe("VerificationRequest", () => {
);
const verificationRequestContent = JSON.parse(await bobUserIdentity.verificationRequestContent(methods));
todoFixupVerificationRequestContent(verificationRequestContent);
await bobOlmMachine.receiveVerificationEvent(
JSON.stringify({
type: "m.room.message",
@@ -405,6 +409,8 @@ describe("VerificationRequest", () => {
);
const verificationRequestContent = JSON.parse(await bobUserIdentity.verificationRequestContent(methods));
todoFixupVerificationRequestContent(verificationRequestContent);
await bobOlmMachine.receiveVerificationEvent(
JSON.stringify({
type: "m.room.message",
@@ -460,6 +466,15 @@ describe("VerificationRequest", () => {
});
});
/**
* Needed until https://github.com/matrix-org/matrix-rust-sdk/issues/5643 is fixed.
*
* Modify the content of the supplied content to include `msgtype: m.key.verification.request`.
*/
function todoFixupVerificationRequestContent(content: any) {
content.msgtype = "m.key.verification.request";
}
describe("isVerificationEvent", () => {
it.each([
[EventType.KeyVerificationCancel],
+43
View File
@@ -571,6 +571,49 @@ describe("SyncAccumulator", function () {
expect(sa.getJSON().roomsData.knock["!knock:bar"]).toBeUndefined();
});
it("should delete knock state when room transitions from knock to join", () => {
const initKnockState = makeKnockState();
sa.accumulate(
syncSkeleton(
{},
{},
{},
{
knock_state: initKnockState,
},
),
);
expect(sa.getJSON().roomsData.knock["!knock:bar"].knock_state).toBe(initKnockState);
// Room transitions from knock to join (e.g., after approval and joining)
const joinState = {
account_data: { events: [] },
ephemeral: { events: [] },
unread_notifications: {},
state: {
events: [member("bob", KnownMembership.Join)],
},
};
const syncResponse = {
next_batch: "abc",
rooms: {
join: {
"!knock:bar": joinState,
},
invite: {},
leave: {},
},
} as unknown as ISyncResponse;
sa.accumulate(syncResponse);
expect(sa.getJSON().roomsData.knock["!knock:bar"]).toBeUndefined();
expect(sa.getJSON().roomsData.join["!knock:bar"].state?.events[0]?.content.membership).toEqual(
KnownMembership.Join,
);
});
it("should accumulate read receipts", () => {
const receipt1 = {
type: "m.receipt",
+3 -1
View File
@@ -53,6 +53,7 @@ import {
} from "../webrtc/callEventTypes.ts";
import {
type IRTCNotificationContent,
type IRTCDeclineContent,
type EncryptionKeysEventContent,
type ICallNotifyContent,
} from "../matrixrtc/types.ts";
@@ -152,6 +153,7 @@ export enum EventType {
// MatrixRTC events
CallNotify = "org.matrix.msc4075.call.notify",
RTCNotification = "org.matrix.msc4075.rtc.notification",
RTCDecline = "org.matrix.msc4310.rtc.decline",
}
export enum RelationType {
@@ -163,7 +165,6 @@ export enum RelationType {
// moreover, our tests currently use the unstable prefix. Use THREAD_RELATION_TYPE.name.
// Once we support *only* the stable prefix, THREAD_RELATION_TYPE can die and we can switch to this.
Thread = "m.thread",
unstable_RTCNotificationParent = "org.matrix.msc4075.rtc.notification.parent",
}
export enum MsgType {
@@ -332,6 +333,7 @@ export interface TimelineEvents {
[EventType.CallEncryptionKeysPrefix]: EncryptionKeysEventContent;
[EventType.CallNotify]: ICallNotifyContent;
[EventType.RTCNotification]: IRTCNotificationContent;
[EventType.RTCDecline]: IRTCDeclineContent;
[M_BEACON.name]: MBeaconEventContent;
[M_POLL_START.name]: PollStartEventContent;
[M_POLL_END.name]: PollEndEventContent;
-5
View File
@@ -27,11 +27,6 @@ import { type EventType, type RelationType, type RoomType } from "./event.ts";
/* eslint-disable camelcase */
export interface IJoinRoomOpts {
/**
* @deprecated does nothing
*/
syncRoom?: boolean;
/**
* If the caller has a keypair 3pid invite, the signing URL is passed in this parameter.
*/
+9 -2
View File
@@ -47,12 +47,19 @@ export const M_TOPIC = new NamespacedValue("m.topic");
/**
* The event content for an m.topic event (in content)
*/
export type MTopicContent = IMessageRendering[];
export type MTopicContent = { "m.text": IMessageRendering[] };
/**
* A previous incorrect form of m.topic used by matrix-js-sdk
* TODO remove this after a few releases
* https://github.com/matrix-org/matrix-js-sdk/pull/4984#pullrequestreview-3174251065
*/
export type MalformedMTopicEvent = { "m.topic": IMessageRendering[] };
/**
* The event definition for an m.topic event (in content)
*/
export type MTopicEvent = { "m.topic": MTopicContent };
export type MTopicEvent = { "m.topic": MTopicContent } | MalformedMTopicEvent;
/**
* The event content for an m.room.topic event
-6
View File
@@ -22,9 +22,3 @@ import { type AuthDict } from "../interactive-auth.ts";
export type UIARequest<T> = T & {
auth?: AuthDict;
};
/**
* Helper type to represent HTTP response body for a UIA enabled endpoint
* @deprecated - a successful response for a UIA enabled endpoint is no different, UIA is signalled via an error
*/
export type UIAResponse<T> = T;
+25 -49
View File
@@ -542,6 +542,7 @@ export const UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS = "uk.half-shot.msc2666.query_m
export const UNSTABLE_MSC4140_DELAYED_EVENTS = "org.matrix.msc4140";
export const UNSTABLE_MSC4133_EXTENDED_PROFILES = "uk.tcpip.msc4133";
export const STABLE_MSC4133_EXTENDED_PROFILES = "uk.tcpip.msc4133.stable";
enum CrossSigningKeyType {
MasterKey = "master_key",
@@ -1085,20 +1086,6 @@ export enum ClientEvent {
*/
ClientWellKnown = "WellKnown.client",
ReceivedVoipEvent = "received_voip_event",
/**
* @deprecated This event is not supported anymore.
*
* Fires if a to-device event is received that cannot be decrypted.
* Encrypted to-device events will (generally) use plain Olm encryption,
* in which case decryption failures are fatal: the event will never be
* decryptable, unlike Megolm encrypted events where the key may simply
* arrive later.
*
* An undecryptable to-device event is therefore likely to indicate problems.
*
* The payload is the undecyptable to-device event
*/
UndecryptableToDeviceEvent = "toDeviceEvent.undecryptable",
TurnServers = "turnServers",
TurnServersError = "turnServers.error",
}
@@ -1163,7 +1150,6 @@ export type ClientEventHandlerMap = {
[ClientEvent.Event]: (event: MatrixEvent) => void;
[ClientEvent.ToDeviceEvent]: (event: MatrixEvent) => void;
[ClientEvent.ReceivedToDeviceMessage]: (payload: ReceivedToDeviceMessage) => void;
[ClientEvent.UndecryptableToDeviceEvent]: (event: MatrixEvent) => void;
[ClientEvent.AccountData]: (event: MatrixEvent, lastEvent?: MatrixEvent) => void;
[ClientEvent.Room]: (room: Room) => void;
[ClientEvent.DeleteRoom]: (roomId: string) => void;
@@ -2155,21 +2141,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public getVisibleRooms(msc3946ProcessDynamicPredecessor = false): Room[] {
const allRooms = this.store.getRooms();
const replacedRooms = new Set();
for (const r of allRooms) {
const predecessor = r.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId;
if (predecessor) {
replacedRooms.add(predecessor);
const visibleRooms = new Set(allRooms);
for (const room of visibleRooms) {
const predecessors = this.findPredecessorRooms(room, true, msc3946ProcessDynamicPredecessor);
for (const predecessor of predecessors) {
visibleRooms.delete(predecessor);
}
}
return allRooms.filter((r) => {
const tombstone = r.currentState.getStateEvents(EventType.RoomTombstone, "");
if (tombstone && replacedRooms.has(r.roomId)) {
return false;
}
return true;
});
return Array.from(visibleRooms);
}
/**
@@ -3604,6 +3583,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return await this.setRoomReadMarkersHttpRequest(roomId, rmEventId, rrEventId, rpEventId);
}
public sendRtcDecline(roomId: string, notificationEventId: string): Promise<ISendEventResponse> {
return this.sendEvent(roomId, EventType.RTCDecline, {
"m.relates_to": { event_id: notificationEventId, rel_type: RelationType.Reference },
});
}
/**
* Get a preview of the given URL as of (roughly) the given point in time,
* described as an object with OpenGraph keys and associated values.
@@ -3860,7 +3845,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
roomId: string,
includeFuture = true,
): Promise<{ [roomId: string]: Error | MatrixError | null }> {
const upgradeHistory = this.getRoomUpgradeHistory(roomId);
const upgradeHistory = this.getRoomUpgradeHistory(roomId, true);
let eligibleToLeave = upgradeHistory;
if (!includeFuture) {
@@ -6862,9 +6847,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* @param opts - options object
*
* @returns Promise which resolves to response object, as
* determined by this.opts.onlyData, opts.rawResponse, and
* opts.onlyContentUri. Rejects with an error (usually a MatrixError).
* @returns Promise which resolves to response object, or rejects with an error (usually a MatrixError).
*/
public uploadContent(file: FileType, opts?: UploadOpts): Promise<UploadResponse> {
return this.http.uploadContent(file, opts);
@@ -6914,7 +6897,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns `true` if supported, otherwise `false`
*/
public async doesServerSupportExtendedProfiles(): Promise<boolean> {
return this.doesServerSupportUnstableFeature(UNSTABLE_MSC4133_EXTENDED_PROFILES);
return (
(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4133_EXTENDED_PROFILES)) ||
(await this.doesServerSupportUnstableFeature(STABLE_MSC4133_EXTENDED_PROFILES))
);
}
/**
@@ -8417,21 +8403,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
}
/**
* Get the OIDC issuer responsible for authentication on this server, if any
* @returns Resolves: A promise of an object containing the OIDC issuer if configured
* @returns Rejects: when the request fails (module:http-api.MatrixError)
* @experimental - part of MSC2965
* @deprecated in favour of getAuthMetadata
*/
public async getAuthIssuer(): Promise<{
issuer: string;
}> {
return this.http.request(Method.Get, "/auth_issuer", undefined, undefined, {
prefix: ClientPrefix.Unstable + "/org.matrix.msc2965",
});
}
/**
* Discover and validate delegated auth configuration
* - delegated auth issuer openid-configuration is reachable
@@ -8451,7 +8422,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
});
} catch (e) {
if (e instanceof MatrixError && e.errcode === "M_UNRECOGNIZED") {
const { issuer } = await this.getAuthIssuer();
// Fall back to older variant of MSC2965
const { issuer } = await this.http.request<{
issuer: string;
}>(Method.Get, "/auth_issuer", undefined, undefined, {
prefix: ClientPrefix.Unstable + "/org.matrix.msc2965",
});
return discoverAndValidateOIDCIssuerWellKnown(issuer);
}
throw e;
+6 -2
View File
@@ -197,7 +197,7 @@ export const makeTopicContent: MakeTopicContent = (topic, htmlTopic) => {
if (isProvided(topic)) {
renderings.push({ body: topic, mimetype: "text/plain" });
}
return { topic, [M_TOPIC.name]: renderings };
return { topic, [M_TOPIC.name]: { "m.text": renderings } };
};
export type TopicState = {
@@ -206,7 +206,11 @@ export type TopicState = {
};
export const parseTopicContent = (content: MRoomTopicEventContent): TopicState => {
const mtopic = M_TOPIC.findIn<MTopicContent>(content);
const mtopicParent = M_TOPIC.findIn<MTopicContent>(content);
const mtopic = Array.isArray(mtopicParent) ? mtopicParent : mtopicParent?.["m.text"];
// TODO remove support for the old malformed m.topic arrays after a few releases (only allow array in m.text)
// https://github.com/matrix-org/matrix-js-sdk/pull/4984#pullrequestreview-3174251065
//const mtopic = M_TOPIC.findIn<MTopicContent>(content)?.["m.text"];
if (!Array.isArray(mtopic)) {
return { text: content.topic ?? undefined };
}
-107
View File
@@ -468,16 +468,6 @@ export interface CryptoApi {
*/
getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[];
/**
* Finds a DM verification request that is already in progress for the given room id
*
* @param roomId - the room to use for verification
*
* @returns the VerificationRequest that is in progress, if any
* @deprecated prefer `userId` parameter variant.
*/
findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined;
/**
* Finds a DM verification request that is already in progress for the given room and user.
*
@@ -545,18 +535,6 @@ export interface CryptoApi {
*/
getSessionBackupPrivateKey(): Promise<Uint8Array | null>;
/**
* Store the backup decryption key.
*
* This should be called if the client has received the key from another device via secret sharing (gossiping).
* It is the responsability of the caller to check that the decryption key is valid for the current backup version.
*
* @param key - the backup decryption key
*
* @deprecated prefer the variant with a `version` parameter.
*/
storeSessionBackupPrivateKey(key: Uint8Array): Promise<void>;
/**
* Store the backup decryption key.
*
@@ -801,45 +779,6 @@ export enum DecryptionFailureCode {
/** Unknown or unclassified error. */
UNKNOWN_ERROR = "UNKNOWN_ERROR",
/** @deprecated only used in legacy crypto */
MEGOLM_BAD_ROOM = "MEGOLM_BAD_ROOM",
/** @deprecated only used in legacy crypto */
MEGOLM_MISSING_FIELDS = "MEGOLM_MISSING_FIELDS",
/** @deprecated only used in legacy crypto */
OLM_DECRYPT_GROUP_MESSAGE_ERROR = "OLM_DECRYPT_GROUP_MESSAGE_ERROR",
/** @deprecated only used in legacy crypto */
OLM_BAD_ENCRYPTED_MESSAGE = "OLM_BAD_ENCRYPTED_MESSAGE",
/** @deprecated only used in legacy crypto */
OLM_BAD_RECIPIENT = "OLM_BAD_RECIPIENT",
/** @deprecated only used in legacy crypto */
OLM_BAD_RECIPIENT_KEY = "OLM_BAD_RECIPIENT_KEY",
/** @deprecated only used in legacy crypto */
OLM_BAD_ROOM = "OLM_BAD_ROOM",
/** @deprecated only used in legacy crypto */
OLM_BAD_SENDER_CHECK_FAILED = "OLM_BAD_SENDER_CHECK_FAILED",
/** @deprecated only used in legacy crypto */
OLM_BAD_SENDER = "OLM_BAD_SENDER",
/** @deprecated only used in legacy crypto */
OLM_FORWARDED_MESSAGE = "OLM_FORWARDED_MESSAGE",
/** @deprecated only used in legacy crypto */
OLM_MISSING_CIPHERTEXT = "OLM_MISSING_CIPHERTEXT",
/** @deprecated only used in legacy crypto */
OLM_NOT_INCLUDED_IN_RECIPIENTS = "OLM_NOT_INCLUDED_IN_RECIPIENTS",
/** @deprecated only used in legacy crypto */
UNKNOWN_ENCRYPTION_ALGORITHM = "UNKNOWN_ENCRYPTION_ALGORITHM",
}
/** Base {@link DeviceIsolationMode} kind. */
@@ -1104,8 +1043,6 @@ export type ImportRoomKeyProgressData = ImportRoomKeyFetchProgress | ImportRoomK
export interface ImportRoomKeysOpts {
/** Reports ongoing progress of the import process. Can be used for feedback. */
progressCallback?: (stage: ImportRoomKeyProgressData) => void;
/** @deprecated the rust SDK will always such imported keys as untrusted */
untrusted?: boolean;
/** @deprecated not useful externally */
source?: string;
}
@@ -1193,13 +1130,6 @@ export interface CryptoCallbacks {
name: string,
) => Promise<[string, Uint8Array] | null>;
/** @deprecated: unused with the Rust crypto stack. */
getCrossSigningKey?: (keyType: string, pubKey: string) => Promise<Uint8Array | null>;
/** @deprecated: unused with the Rust crypto stack. */
saveCrossSigningKeys?: (keys: Record<string, Uint8Array>) => void;
/** @deprecated: unused with the Rust crypto stack. */
shouldUpgradeDeviceVerifications?: (users: Record<string, any>) => Promise<string[]>;
/**
* Called by {@link CryptoApi.bootstrapSecretStorage} when a new default secret storage key is created.
*
@@ -1211,24 +1141,6 @@ export interface CryptoCallbacks {
* @param key - private key to store
*/
cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void;
/** @deprecated: unused with the Rust crypto stack. */
onSecretRequested?: (
userId: string,
deviceId: string,
requestId: string,
secretName: string,
deviceTrust: DeviceVerificationStatus,
) => Promise<string | undefined>;
/** @deprecated: unused with the Rust crypto stack. */
getDehydrationKey?: (
keyInfo: SecretStorageKeyDescription,
checkFunc: (key: Uint8Array) => void,
) => Promise<Uint8Array>;
/** @deprecated: unused with the Rust crypto stack. */
getBackupKey?: () => Promise<Uint8Array>;
}
/**
@@ -1243,13 +1155,6 @@ export interface CreateSecretStorageOpts {
*/
createSecretStorageKey?: () => Promise<GeneratedSecretStorageKey>;
/**
* The current key backup object. If passed,
* the passphrase and recovery key from this backup will be used.
* @deprecated Not used by the Rust crypto stack.
*/
keyBackupInfo?: KeyBackupInfo;
/**
* If true, a new key backup version will be
* created and the private key stored in the new SSSS store. Ignored if keyBackupInfo
@@ -1261,18 +1166,6 @@ export interface CreateSecretStorageOpts {
* Reset even if keys already exist.
*/
setupNewSecretStorage?: boolean;
/**
* Function called to get the user's current key backup passphrase.
*
* Should return a promise that resolves with a Uint8Array
* containing the key, or rejects if the key cannot be obtained.
*
* Only used when the client has existing key backup, but no secret storage.
*
* @deprecated Not used by the Rust crypto stack.
*/
getKeyBackupPassphrase?: () => Promise<Uint8Array>;
}
/** Types of cross-signing key */
-28
View File
@@ -114,25 +114,6 @@ export interface VerificationRequest
*/
cancel(params?: { reason?: string; code?: string }): Promise<void>;
/**
* Create a {@link Verifier} to do this verification via a particular method.
*
* If a verifier has already been created for this request, returns that verifier.
*
* This does *not* send the `m.key.verification.start` event - to do so, call {@link Verifier.verify} on the
* returned verifier.
*
* If no previous events have been sent, pass in `targetDevice` to set who to direct this request to.
*
* @param method - the name of the verification method to use.
* @param targetDevice - details of where to send the request to.
*
* @returns The verifier which will do the actual verification.
*
* @deprecated Use {@link VerificationRequest#startVerification} instead.
*/
beginKeyVerification(method: string, targetDevice?: { userId?: string; deviceId?: string }): Verifier;
/**
* Send an `m.key.verification.start` event to start verification via a particular method.
*
@@ -163,15 +144,6 @@ export interface VerificationRequest
*/
get verifier(): Verifier | undefined;
/**
* Get the data for a QR code allowing the other device to verify this one, if it supports it.
*
* Only set after a .ready if the other party can scan a QR code, otherwise undefined.
*
* @deprecated Not supported in Rust Crypto. Use {@link VerificationRequest#generateQRCode} instead.
*/
getQRCodeBytes(): Uint8ClampedArray | undefined;
/**
* Generate the data for a QR code allowing the other device to verify this one, if it supports it.
*
-3
View File
@@ -25,9 +25,6 @@ export interface MapperOpts {
preventReEmit?: boolean;
// decrypt event proactively
decrypt?: boolean;
/** @deprecated no longer used */
toDevice?: boolean;
}
export function eventMapperFor(client: MatrixClient, options: MapperOpts): EventMapper {
+17 -54
View File
@@ -34,22 +34,6 @@ import { anySignal, parseErrorResponse, timeoutSignal } from "./utils.ts";
import { type QueryDict } from "../utils.ts";
import { TokenRefresher, TokenRefreshOutcome } from "./refresh.ts";
interface TypedResponse<T> extends Response {
json(): Promise<T>;
}
/**
* The type returned by {@link FetchHttpApi.request}, etc.
*
* If {@link IHttpOpts.onlyData} is unset or false, then the request methods return a
* {@link https://developer.mozilla.org/en-US/docs/Web/API/Response Response} object,
* which we abstract via `TypedResponse`. Otherwise, we just cast it to `T`.
*
* @typeParam T - The type (specified by the application on the request method) that we will cast the response to.
* @typeParam O - The type of the options object on the {@link FetchHttpApi} instance.
*/
export type ResponseType<T, O extends IHttpOpts> = O extends { onlyData: true } | undefined ? T : TypedResponse<T>;
export class FetchHttpApi<O extends IHttpOpts> {
private abortController = new AbortController();
private readonly tokenRefresher: TokenRefresher;
@@ -59,7 +43,9 @@ export class FetchHttpApi<O extends IHttpOpts> {
public readonly opts: O,
) {
checkObjectHasKeys(opts, ["baseUrl", "prefix"]);
opts.onlyData = !!opts.onlyData;
if (!opts.onlyData) {
throw new Error("Constructing FetchHttpApi without `onlyData=true` is no longer supported.");
}
opts.useAuthorizationHeader = opts.useAuthorizationHeader ?? true;
this.tokenRefresher = new TokenRefresher(opts);
@@ -91,7 +77,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
params: Record<string, string | string[]> | undefined,
prefix: string,
accessToken?: string,
): Promise<ResponseType<T, O>> {
): Promise<T> {
if (!this.opts.idBaseUrl) {
throw new Error("No identity server base URL set");
}
@@ -132,17 +118,8 @@ export class FetchHttpApi<O extends IHttpOpts> {
* When `paramOpts.doNotAttemptTokenRefresh` is true, token refresh will not be attempted
* when an expired token is encountered. Used to only attempt token refresh once.
*
* @returns Promise which resolves to
* ```
* {
* data: {Object},
* headers: {Object},
* code: {Number},
* }
* ```
* If `onlyData` is set, this will resolve to the `data` object only.
* @returns Rejects with an error if a problem occurred.
* This includes network problems and Matrix-specific error JSON.
* @returns The parsed response.
* @throws Error if a problem occurred. This includes network problems and Matrix-specific error JSON.
*/
public authedRequest<T>(
method: Method,
@@ -150,7 +127,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
queryParams: QueryDict = {},
body?: Body,
paramOpts: IRequestOpts = {},
): Promise<ResponseType<T, O>> {
): Promise<T> {
return this.doAuthedRequest<T>(1, method, path, queryParams, body, paramOpts);
}
@@ -162,7 +139,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
queryParams: QueryDict,
body?: Body,
paramOpts: IRequestOpts = {},
): Promise<ResponseType<T, O>> {
): Promise<T> {
// avoid mutating paramOpts so they can be used on retry
const opts = deepCopy(paramOpts);
// we have to manually copy the abortSignal over as it is not a plain object
@@ -228,18 +205,8 @@ export class FetchHttpApi<O extends IHttpOpts> {
*
* @param opts - additional options
*
* @returns Promise which resolves to
* ```
* {
* data: {Object},
* headers: {Object},
* code: {Number},
* }
* ```
* If `onlyData</code> is set, this will resolve to the <code>data`
* object only.
* @returns Rejects with an error if a problem
* occurred. This includes network problems and Matrix-specific error JSON.
* @returns The parsed response.
* @throws Error if a problem occurred. This includes network problems and Matrix-specific error JSON.
*/
public request<T>(
method: Method,
@@ -247,7 +214,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
queryParams?: QueryDict,
body?: Body,
opts?: IRequestOpts,
): Promise<ResponseType<T, O>> {
): Promise<T> {
const fullUri = this.getUrl(path, queryParams, opts?.prefix, opts?.baseUrl);
return this.requestOtherUrl<T>(method, fullUri, body, opts);
}
@@ -261,17 +228,15 @@ export class FetchHttpApi<O extends IHttpOpts> {
*
* @param opts - additional options
*
* @returns Promise which resolves to data unless `onlyData` is specified as false,
* where the resolved value will be a fetch Response object.
* @returns Rejects with an error if a problem
* occurred. This includes network problems and Matrix-specific error JSON.
* @returns The parsed response.
* @throws Error if a problem occurred. This includes network problems and Matrix-specific error JSON.
*/
public async requestOtherUrl<T>(
method: Method,
url: URL | string,
body?: Body,
opts: BaseRequestOpts = {},
): Promise<ResponseType<T, O>> {
): Promise<T> {
if (opts.json !== undefined && opts.rawResponseBody !== undefined) {
throw new Error("Invalid call to `FetchHttpApi` sets both `opts.json` and `opts.rawResponseBody`");
}
@@ -349,14 +314,12 @@ export class FetchHttpApi<O extends IHttpOpts> {
throw parseErrorResponse(res, await res.text());
}
if (!this.opts.onlyData) {
return res as ResponseType<T, O>;
} else if (opts.rawResponseBody) {
return (await res.blob()) as ResponseType<T, O>;
if (opts.rawResponseBody) {
return (await res.blob()) as T;
} else if (jsonResponse) {
return await res.json();
} else {
return (await res.text()) as ResponseType<T, O>;
return (await res.text()) as T;
}
}
+2 -8
View File
@@ -48,9 +48,7 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
*
* @param opts - options object
*
* @returns Promise which resolves to response object, as
* determined by this.opts.onlyData, opts.rawResponse, and
* opts.onlyContentUri. Rejects with an error (usually a MatrixError).
* @returns Promise which resolves to response object, or rejects with an error (usually a MatrixError).
*/
public uploadContent(file: FileType, opts: UploadOpts = {}): Promise<UploadResponse> {
const includeFilename = opts.includeFilename ?? true;
@@ -149,11 +147,7 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
prefix: MediaPrefix.V3,
headers,
abortSignal: abortController.signal,
})
.then((response) => {
return this.opts.onlyData ? <UploadResponse>response : response.json();
})
.then(uploadResolvers.resolve, uploadResolvers.reject);
}).then(uploadResolvers.resolve, uploadResolvers.reject);
}
// remove the upload from the list on completion
+6 -11
View File
@@ -69,10 +69,7 @@ export interface IHttpOpts {
tokenRefreshFunction?: TokenRefreshFunction;
useAuthorizationHeader?: boolean; // defaults to true
/**
* Normally, methods in `FetchHttpApi` will return a {@link https://developer.mozilla.org/en-US/docs/Web/API/Response Response} object.
* If this is set to `true`, they instead return the response body.
*/
/** For historical reasons, must be set to `true`. Will eventually be removed. */
onlyData?: boolean;
localTimeoutMs?: number;
@@ -103,11 +100,10 @@ export interface BaseRequestOpts extends Pick<RequestInit, "priority"> {
*
* * Set `Accept: application/json` in the request headers (again, unless overridden by {@link headers}).
*
* * If `IHTTPOpts.onlyData` is set to `true` on the `FetchHttpApi` instance, parse the response as
* JSON and return the parsed response.
* * Parse the response as JSON and return the parsed response.
*
* Setting this to `false` inhibits all three behaviors, and (if `IHTTPOpts.onlyData` is set to `true`) the response
* is instead parsed as a UTF-8 string. It defaults to `true`, unless {@link rawResponseBody} is set.
* Setting this to `false` inhibits all three behaviors, and the response is instead parsed as a UTF-8 string. It
* defaults to `true`, unless {@link rawResponseBody} is set.
*
* @deprecated Instead of setting this to `false`, set {@link rawResponseBody} to `true`.
*/
@@ -118,9 +114,8 @@ export interface BaseRequestOpts extends Pick<RequestInit, "priority"> {
*
* * Inhibits the automatic addition of `Accept: application/json` in the request headers.
*
* * Assuming `IHTTPOpts.onlyData` is set to `true` on the `FetchHttpApi` instance, causes the
* raw response to be returned as a {@link https://developer.mozilla.org/en-US/docs/Web/API/Blob|Blob}
* instead of parsing it as `json`.
* * Causes the raw response to be returned as a {@link https://developer.mozilla.org/en-US/docs/Web/API/Blob|Blob}
* instead of parsing it as JSON.
*/
rawResponseBody?: boolean;
}
+11
View File
@@ -18,6 +18,7 @@ import { type MatrixEvent } from "../matrix.ts";
import { deepCompare } from "../utils.ts";
import { type Focus } from "./focus.ts";
import { isLivekitFocusActive } from "./LivekitFocus.ts";
import { type SessionDescription } from "./MatrixRTCSession.ts";
/**
* The default duration in milliseconds that a membership is considered valid for.
@@ -130,6 +131,9 @@ export class CallMembership {
return this.parentEvent.getId();
}
/**
* @deprecated Use sessionDescription.id instead.
*/
public get callId(): string {
return this.membershipData.call_id;
}
@@ -138,6 +142,13 @@ export class CallMembership {
return this.membershipData.device_id;
}
public get sessionDescription(): SessionDescription {
return {
application: this.membershipData.application,
id: this.membershipData.call_id,
};
}
public get application(): string | undefined {
return this.membershipData.application;
}
+16 -5
View File
@@ -17,13 +17,21 @@ limitations under the License.
import type { CallMembership } from "./CallMembership.ts";
import type { Focus } from "./focus.ts";
import type { Status } from "./types.ts";
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
export enum MembershipManagerEvent {
StatusChanged = "StatusChanged",
/**
* Emitted when the membership manager has not heard back from the server for the duration
* of the delayed event and hence failed to restart the delayed event.
* This means that the user is probably not joined anymore and the leave event was distributed to other session members.
*/
ProbablyLeft = "ProbablyLeft",
}
export type MembershipManagerEventHandlerMap = {
[MembershipManagerEvent.StatusChanged]: (prefStatus: Status, newStatus: Status) => void;
[MembershipManagerEvent.ProbablyLeft]: (probablyLeft: boolean) => void;
};
/**
@@ -33,7 +41,8 @@ export type MembershipManagerEventHandlerMap = {
*
* @internal
*/
export interface IMembershipManager {
export interface IMembershipManager
extends TypedEventEmitter<MembershipManagerEvent, MembershipManagerEventHandlerMap> {
/**
* If we are trying to join, or have successfully joined the session.
* It does not reflect if the room state is already configured to represent us being joined.
@@ -62,6 +71,12 @@ export interface IMembershipManager {
*/
get ownMembership(): CallMembership | undefined;
/**
* If the membership manager has reason to believe that the hs sent a leave event
* and as a consequence the current user is perceived as left for other session participants.
*/
get probablyLeft(): boolean;
/**
* Start sending all necessary events to make this user participate in the RTC session.
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
@@ -85,8 +100,4 @@ export interface IMembershipManager {
* @returns the used active focus in the currently joined session or undefined if not joined.
*/
getActiveFocus(): Focus | undefined;
// TypedEventEmitter methods:
on(event: MembershipManagerEvent.StatusChanged, listener: (oldStatus: Status, newStatus: Status) => void): this;
off(event: MembershipManagerEvent.StatusChanged, listener: (oldStatus: Status, newStatus: Status) => void): this;
}
+133 -41
View File
@@ -20,16 +20,27 @@ import { EventTimeline } from "../models/event-timeline.ts";
import { type Room } from "../models/room.ts";
import { type MatrixClient } from "../client.ts";
import { EventType, RelationType } from "../@types/event.ts";
import { KnownMembership } from "../@types/membership.ts";
import { type ISendEventResponse } from "../@types/requests.ts";
import { CallMembership } from "./CallMembership.ts";
import { RoomStateEvent } from "../models/room-state.ts";
import { type Focus } from "./focus.ts";
import { KnownMembership } from "../@types/membership.ts";
import { MembershipManager } from "./MembershipManager.ts";
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
import { logDurationSync } from "../utils.ts";
import { type Statistics, type RTCNotificationType } from "./types.ts";
import { deepCompare, logDurationSync } from "../utils.ts";
import {
type Statistics,
type RTCNotificationType,
type Status,
type IRTCNotificationContent,
type ICallNotifyContent,
} from "./types.ts";
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
import type { IMembershipManager } from "./IMembershipManager.ts";
import {
MembershipManagerEvent,
type MembershipManagerEventHandlerMap,
type IMembershipManager,
} from "./IMembershipManager.ts";
import { RTCEncryptionManager } from "./RTCEncryptionManager.ts";
import {
RoomAndToDeviceEvents,
@@ -39,6 +50,9 @@ import {
import { TypedReEmitter } from "../ReEmitter.ts";
import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
/**
* Events emitted by MatrixRTCSession
*/
export enum MatrixRTCSessionEvent {
// A member joined, left, or updated a property of their membership.
MembershipsChanged = "memberships_changed",
@@ -50,6 +64,8 @@ export enum MatrixRTCSessionEvent {
EncryptionKeyChanged = "encryption_key_changed",
/** The membership manager had to shut down caused by an unrecoverable error */
MembershipManagerError = "membership_manager_error",
/** The RTCSession did send a call notification caused by joining the call as the first member */
DidSendCallNotification = "did_send_call_notification",
}
export type MatrixRTCSessionEventHandlerMap = {
@@ -64,6 +80,10 @@ export type MatrixRTCSessionEventHandlerMap = {
participantId: string,
) => void;
[MatrixRTCSessionEvent.MembershipManagerError]: (error: unknown) => void;
[MatrixRTCSessionEvent.DidSendCallNotification]: (
notificationContentNew: { event_id: string } & IRTCNotificationContent,
notificationContentLegacy: { event_id: string } & ICallNotifyContent,
) => void;
};
export interface SessionConfig {
@@ -74,6 +94,14 @@ export interface SessionConfig {
notificationType?: RTCNotificationType;
}
/**
* The session description is used to identify a session. Used in the state event.
*/
export interface SessionDescription {
id: string;
application: string;
}
// The names follow these principles:
// - we use the technical term delay if the option is related to delayed events.
// - we use delayedLeaveEvent if the option is related to the delayed leave event.
@@ -86,7 +114,7 @@ export interface MembershipConfig {
* Use the new Manager.
*
* Default: `false`.
* @deprecated does nothing anymore we always default to the new memberhip manager.
* @deprecated does nothing anymore we always default to the new membership manager.
*/
useNewMembershipManager?: boolean;
@@ -97,8 +125,6 @@ export interface MembershipConfig {
* This is what goes into the m.rtc.member event expiry field and is typically set to a number of hours.
*/
membershipEventExpiryMs?: number;
/** @deprecated renamed to `membershipEventExpiryMs`*/
membershipExpiryTimeout?: number;
/**
* The time in (in milliseconds) which the manager will prematurely send the updated state event before the membership `expires` time to make sure it
@@ -110,23 +136,17 @@ export interface MembershipConfig {
* This value does not have an effect on the value of `SessionMembershipData.expires`.
*/
membershipEventExpiryHeadroomMs?: number;
/** @deprecated renamed to `membershipEventExpiryHeadroomMs`*/
membershipExpiryTimeoutHeadroom?: number;
/**
* The timeout (in milliseconds) with which the deleayed leave event on the server is configured.
* After this time the server will set the event to the disconnected stat if it has not received a keep-alive from the client.
*/
delayedLeaveEventDelayMs?: number;
/** @deprecated renamed to `delayedLeaveEventDelayMs`*/
membershipServerSideExpiryTimeout?: number;
/**
* The interval (in milliseconds) in which the client will send membership keep-alives to the server.
*/
delayedLeaveEventRestartMs?: number;
/** @deprecated renamed to `delayedLeaveEventRestartMs`*/
membershipKeepAlivePeriod?: number;
/**
* The maximum number of retries that the manager will do for delayed event sending/updating and state event sending when a server rate limit has been hit.
@@ -144,9 +164,6 @@ export interface MembershipConfig {
*/
networkErrorRetryMs?: number;
/** @deprecated renamed to `networkErrorRetryMs`*/
callMemberEventRetryDelayMinimum?: number;
/**
* If true, use the new to-device transport for sending encryption keys.
*/
@@ -212,8 +229,8 @@ export type JoinSessionConfig = SessionConfig & MembershipConfig & EncryptionCon
* This class doesn't deal with media at all, just membership & properties of a session.
*/
export class MatrixRTCSession extends TypedEventEmitter<
MatrixRTCSessionEvent | RoomAndToDeviceEvents,
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap & MembershipManagerEventHandlerMap
> {
private membershipManager?: IMembershipManager;
private encryptionManager?: IEncryptionManager;
@@ -243,6 +260,14 @@ export class MatrixRTCSession extends TypedEventEmitter<
},
};
public get membershipStatus(): Status | undefined {
return this.membershipManager?.status;
}
public get probablyLeft(): boolean | undefined {
return this.membershipManager?.probablyLeft;
}
/**
* The callId (sessionId) of the call.
*
@@ -254,10 +279,27 @@ export class MatrixRTCSession extends TypedEventEmitter<
}
/**
* Returns all the call memberships for a room, oldest first
* Returns all the call memberships for a room that match the provided `sessionDescription`,
* oldest first.
*
* @deprecated Use `MatrixRTCSession.sessionMembershipsForRoom` instead.
*/
public static callMembershipsForRoom(
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
): CallMembership[] {
return MatrixRTCSession.sessionMembershipsForRoom(room, {
id: "",
application: "m.call",
});
}
/**
* Returns all the call memberships for a room that match the provided `sessionDescription`,
* oldest first.
*/
public static sessionMembershipsForRoom(
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
sessionDescription: SessionDescription,
): CallMembership[] {
const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`);
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
@@ -290,9 +332,10 @@ export class MatrixRTCSession extends TypedEventEmitter<
try {
const membership = new CallMembership(memberEvent, membershipData);
if (membership.callId !== "" || membership.scope !== "m.room") {
// for now, just ignore anything that isn't a room scope call
logger.info(`Ignoring user-scoped call`);
if (!deepCompare(membership.sessionDescription, sessionDescription)) {
logger.info(
`Ignoring membership of user ${membership.sender} for a different session: ${JSON.stringify(membership.sessionDescription)}`,
);
continue;
}
@@ -323,12 +366,33 @@ export class MatrixRTCSession extends TypedEventEmitter<
}
/**
* Return the MatrixRTC session for the room, whether there are currently active members or not
* Return the MatrixRTC session for the room.
* This returned session can be used to find out if there are active room call sessions
* for the requested room.
*
* This method is an alias for `MatrixRTCSession.sessionForRoom` with
* sessionDescription `{ id: "", application: "m.call" }`.
*
* @deprecated Use `MatrixRTCSession.sessionForRoom` with sessionDescription `{ id: "", application: "m.call" }` instead.
*/
public static roomSessionForRoom(client: MatrixClient, room: Room): MatrixRTCSession {
const callMemberships = MatrixRTCSession.callMembershipsForRoom(room);
const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, { id: "", application: "m.call" });
return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" });
}
return new MatrixRTCSession(client, room, callMemberships);
/**
* Return the MatrixRTC session for the room.
* This returned session can be used to find out if there are active sessions
* for the requested room and `sessionDescription`.
*/
public static sessionForRoom(
client: MatrixClient,
room: Room,
sessionDescription: SessionDescription,
): MatrixRTCSession {
const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, sessionDescription);
return new MatrixRTCSession(client, room, callMemberships, sessionDescription);
}
/**
@@ -373,10 +437,15 @@ export class MatrixRTCSession extends TypedEventEmitter<
"getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState" | "on" | "off"
>,
public memberships: CallMembership[],
/**
* The session description is used to define the exact session this object is tracking.
* A session is distinct from another session if one of those properties differ: `roomSubset.roomId`, `sessionDescription.application`, `sessionDescription.id`.
*/
public readonly sessionDescription: SessionDescription,
) {
super();
this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`);
this._callId = memberships[0]?.callId;
this._callId = memberships[0]?.sessionDescription.id;
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
// TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager
roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
@@ -404,8 +473,8 @@ export class MatrixRTCSession extends TypedEventEmitter<
roomState?.off(RoomStateEvent.Members, this.onRoomMemberUpdate);
}
private reEmitter = new TypedReEmitter<
MatrixRTCSessionEvent | RoomAndToDeviceEvents,
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap & MembershipManagerEventHandlerMap
>(this);
/**
@@ -434,9 +503,14 @@ export class MatrixRTCSession extends TypedEventEmitter<
this.roomSubset,
this.client,
() => this.getOldestMembership(),
this.sessionDescription,
this.logger,
);
this.reEmitter.reEmit(this.membershipManager!, [
MembershipManagerEvent.ProbablyLeft,
MembershipManagerEvent.StatusChanged,
]);
// Create Encryption manager
let transport;
if (joinConfig?.useExperimentalToDeviceTransport) {
@@ -594,29 +668,47 @@ export class MatrixRTCSession extends TypedEventEmitter<
* Sends a notification corresponding to the configured notify type.
*/
private sendCallNotify(parentEventId: string, notificationType: RTCNotificationType): void {
// Send legacy event:
this.client
.sendEvent(this.roomSubset.roomId, EventType.CallNotify, {
const sendLegacyNotificationEvent = async (): Promise<{
response: ISendEventResponse;
content: ICallNotifyContent;
}> => {
const content: ICallNotifyContent = {
"application": "m.call",
"m.mentions": { user_ids: [], room: true },
"notify_type": notificationType === "notification" ? "notify" : notificationType,
"call_id": this.callId!,
})
.catch((e) => this.logger.error("Failed to send call notification", e));
// Send new event:
this.client
.sendEvent(this.roomSubset.roomId, EventType.RTCNotification, {
};
const response = await this.client.sendEvent(this.roomSubset.roomId, EventType.CallNotify, content);
return { response, content };
};
const sendNewNotificationEvent = async (): Promise<{
response: ISendEventResponse;
content: IRTCNotificationContent;
}> => {
const content: IRTCNotificationContent = {
"m.mentions": { user_ids: [], room: true },
"notification_type": notificationType,
"m.relates_to": {
event_id: parentEventId,
rel_type: RelationType.unstable_RTCNotificationParent,
rel_type: RelationType.Reference,
},
"sender_ts": Date.now(),
"lifetime": 30_000, // 30 seconds
};
const response = await this.client.sendEvent(this.roomSubset.roomId, EventType.RTCNotification, content);
return { response, content };
};
void Promise.all([sendLegacyNotificationEvent(), sendNewNotificationEvent()])
.then(([legacy, newNotification]) => {
// Join event_id and origin event content
const legacyResult = { ...legacy.response, ...legacy.content };
const newResult = { ...newNotification.response, ...newNotification.content };
this.emit(MatrixRTCSessionEvent.DidSendCallNotification, newResult, legacyResult);
})
.catch((e) => this.logger.error("Failed to send call notification", e));
.catch(([errorLegacy, errorNew]) =>
this.logger.error("Failed to send call notification", errorLegacy, errorNew),
);
}
/**
@@ -642,9 +734,9 @@ export class MatrixRTCSession extends TypedEventEmitter<
*/
private recalculateSessionMembers = (): void => {
const oldMemberships = this.memberships;
this.memberships = MatrixRTCSession.callMembershipsForRoom(this.room);
this.memberships = MatrixRTCSession.sessionMembershipsForRoom(this.room, this.sessionDescription);
this._callId = this._callId ?? this.memberships[0]?.callId;
this._callId = this._callId ?? this.memberships[0]?.sessionDescription.id;
const changed =
oldMemberships.length != this.memberships.length ||
+10 -3
View File
@@ -20,7 +20,7 @@ import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type Room } from "../models/room.ts";
import { type RoomState, RoomStateEvent } from "../models/room-state.ts";
import { type MatrixEvent } from "../models/event.ts";
import { MatrixRTCSession } from "./MatrixRTCSession.ts";
import { MatrixRTCSession, type SessionDescription } from "./MatrixRTCSession.ts";
import { EventType } from "../@types/event.ts";
export enum MatrixRTCSessionManagerEvents {
@@ -37,6 +37,9 @@ type EventHandlerMap = {
/**
* Holds all active MatrixRTC session objects and creates new ones as events arrive.
* One `MatrixRTCSessionManager` is required for each MatrixRTC sessionDescription (application, session id) that the client wants to support.
* If no application type is specified in the constructor, the default is "m.call".
*
* This interface is UNSTABLE and may change without warning.
*/
export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionManagerEvents, EventHandlerMap> {
@@ -53,6 +56,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
public constructor(
rootLogger: Logger,
private client: MatrixClient,
private readonly sessionDescription: SessionDescription = { id: "", application: "m.call" }, // Default to the Matrix Call application
) {
super();
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
@@ -62,7 +66,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
// We shouldn't need to null-check here, but matrix-client.spec.ts mocks getRooms
// returning nothing, and breaks tests if you change it to return an empty array :'(
for (const room of this.client.getRooms() ?? []) {
const session = MatrixRTCSession.roomSessionForRoom(this.client, room);
const session = MatrixRTCSession.sessionForRoom(this.client, room, this.sessionDescription);
if (session.memberships.length > 0) {
this.roomSessions.set(room.roomId, session);
}
@@ -96,7 +100,10 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
*/
public getRoomSession(room: Room): MatrixRTCSession {
if (!this.roomSessions.has(room.roomId)) {
this.roomSessions.set(room.roomId, MatrixRTCSession.roomSessionForRoom(this.client, room));
this.roomSessions.set(
room.roomId,
MatrixRTCSession.sessionForRoom(this.client, room, this.sessionDescription),
);
}
return this.roomSessions.get(room.roomId)!;
+61 -27
View File
@@ -26,7 +26,7 @@ import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipDat
import { type Focus } from "./focus.ts";
import { isMyMembership, Status } from "./types.ts";
import { isLivekitFocusActive } from "./LivekitFocus.ts";
import { type MembershipConfig } from "./MatrixRTCSession.ts";
import { type SessionDescription, type MembershipConfig } from "./MatrixRTCSession.ts";
import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import {
@@ -101,7 +101,7 @@ export enum MembershipActionType {
// -> MembershipActionType.Update if the timeout has passed so the next update is required.
SendScheduledDelayedLeaveEvent = "SendScheduledDelayedLeaveEvent",
// -> MembershipActionType.SendLeaveEvent on failiour (not found) we need to send the leave manually and cannot use the scheduled delayed event
// -> MembershipActionType.SendLeaveEvent on failure (not found) we need to send the leave manually and cannot use the scheduled delayed event
// -> DelayedLeaveActionType.SendScheduledDelayedLeaveEvent on error we try again.
SendLeaveEvent = "SendLeaveEvent",
@@ -129,6 +129,12 @@ export interface MembershipManagerState {
rateLimitRetries: Map<MembershipActionType, number>;
/** Retry counter for other errors */
networkErrorRetries: Map<MembershipActionType, number>;
/** The time at which we expect the server to send the delayed leave event. */
expectedServerDelayLeaveTs?: number;
/** This is used to track if the client expects the scheduled delayed leave event to have
* been sent because restarting failed during the available time.
* Once we resend the delayed event or successfully restarted it will get unset. */
probablyLeft: boolean;
}
/**
@@ -294,6 +300,7 @@ export class MembershipManager
| "_unstable_updateDelayedEvent"
>,
private getOldestMembership: () => CallMembership | undefined,
public readonly sessionDescription: SessionDescription,
parentLogger?: Logger,
) {
super();
@@ -342,6 +349,7 @@ export class MembershipManager
rateLimitRetries: new Map(),
networkErrorRetries: new Map(),
expireUpdateIterations: 1,
probablyLeft: false,
};
}
// Membership Event static parameters:
@@ -354,35 +362,22 @@ export class MembershipManager
private delayedLeaveEventDelayMsOverride?: number;
private get networkErrorRetryMs(): number {
return this.joinConfig?.networkErrorRetryMs ?? this.joinConfig?.callMemberEventRetryDelayMinimum ?? 3_000;
return this.joinConfig?.networkErrorRetryMs ?? 3_000;
}
private get membershipEventExpiryMs(): number {
return (
this.joinConfig?.membershipEventExpiryMs ??
this.joinConfig?.membershipExpiryTimeout ??
DEFAULT_EXPIRE_DURATION
);
return this.joinConfig?.membershipEventExpiryMs ?? DEFAULT_EXPIRE_DURATION;
}
private get membershipEventExpiryHeadroomMs(): number {
return (
this.joinConfig?.membershipEventExpiryHeadroomMs ??
this.joinConfig?.membershipExpiryTimeoutHeadroom ??
5_000
);
return this.joinConfig?.membershipEventExpiryHeadroomMs ?? 5_000;
}
private computeNextExpiryActionTs(iteration: number): number {
return this.state.startTime + this.membershipEventExpiryMs * iteration - this.membershipEventExpiryHeadroomMs;
}
private get delayedLeaveEventDelayMs(): number {
return (
this.delayedLeaveEventDelayMsOverride ??
this.joinConfig?.delayedLeaveEventDelayMs ??
this.joinConfig?.membershipServerSideExpiryTimeout ??
8_000
);
return this.delayedLeaveEventDelayMsOverride ?? this.joinConfig?.delayedLeaveEventDelayMs ?? 8_000;
}
private get delayedLeaveEventRestartMs(): number {
return this.joinConfig?.delayedLeaveEventRestartMs ?? this.joinConfig?.membershipKeepAlivePeriod ?? 5_000;
return this.joinConfig?.delayedLeaveEventRestartMs ?? 5_000;
}
private get maximumRateLimitRetryCount(): number {
return this.joinConfig?.maximumRateLimitRetryCount ?? 10;
@@ -465,6 +460,8 @@ export class MembershipManager
this.stateKey,
)
.then((response) => {
this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs;
this.setAndEmitProbablyLeft(false);
// On success we reset retries and set delayId.
this.resetRateLimitCounter(MembershipActionType.SendDelayedEvent);
this.state.delayId = response.delay_id;
@@ -544,27 +541,58 @@ export class MembershipManager
});
}
private setAndEmitProbablyLeft(probablyLeft: boolean): void {
if (this.state.probablyLeft === probablyLeft) {
return;
}
this.state.probablyLeft = probablyLeft;
this.emit(MembershipManagerEvent.ProbablyLeft, this.state.probablyLeft);
}
private async restartDelayedEvent(delayId: string): Promise<ActionUpdate> {
// Compute the duration until we expect the server to send the delayed leave event.
const durationUntilServerDelayedLeave = this.state.expectedServerDelayLeaveTs
? this.state.expectedServerDelayLeaveTs - Date.now()
: undefined;
const abortPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new AbortError("Restart delayed event timed out before the HS responded"));
}, this.delayedLeaveEventRestartLocalTimeoutMs);
setTimeout(
() => {
reject(new AbortError("Restart delayed event timed out before the HS responded"));
},
// We abort immediately at the time where we expect the server to send the delayed leave event.
// At this point we want the catch block to run and set the `probablyLeft` state.
//
// While we are already in probablyLeft state, we use the unaltered delayedLeaveEventRestartLocalTimeoutMs.
durationUntilServerDelayedLeave !== undefined && !this.state.probablyLeft
? Math.min(this.delayedLeaveEventRestartLocalTimeoutMs, durationUntilServerDelayedLeave)
: this.delayedLeaveEventRestartLocalTimeoutMs,
);
});
// 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 ge lost. That is why we mimic the AbortError using the race.
// 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,
])
.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
// successful restart requests.
this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs;
this.resetRateLimitCounter(MembershipActionType.RestartDelayedEvent);
this.setAndEmitProbablyLeft(false);
return createInsertActionUpdate(
MembershipActionType.RestartDelayedEvent,
this.delayedLeaveEventRestartMs,
);
})
.catch((e) => {
if (this.state.expectedServerDelayLeaveTs && this.state.expectedServerDelayLeaveTs <= Date.now()) {
// Once we reach this point it's likely that the server is sending the delayed leave event so we emit `probablyLeft = true`.
// It will emit `probablyLeft = false` once we notice about our leave through sync and successfully setup a new state event.
this.setAndEmitProbablyLeft(true);
}
const repeatActionType = MembershipActionType.RestartDelayedEvent;
if (this.isNotFoundError(e)) {
this.state.delayId = undefined;
@@ -619,6 +647,7 @@ export class MembershipManager
this.stateKey,
)
.then(() => {
this.setAndEmitProbablyLeft(false);
this.state.startTime = Date.now();
// The next update should already use twice the membershipEventExpiryTimeout
this.state.expireUpdateIterations = 1;
@@ -700,7 +729,7 @@ export class MembershipManager
// HELPERS
private makeMembershipStateKey(localUserId: string, localDeviceId: string): string {
const stateKey = `${localUserId}_${localDeviceId}`;
const stateKey = `${localUserId}_${localDeviceId}_${this.sessionDescription.application}${this.sessionDescription.id}`;
if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) {
return stateKey;
} else {
@@ -713,9 +742,10 @@ export class MembershipManager
*/
private makeMyMembership(expires: number): SessionMembershipData {
return {
call_id: "",
// TODO: use the new format for m.rtc.member events where call_id becomes session.id
application: this.sessionDescription.application,
call_id: this.sessionDescription.id,
scope: "m.room",
application: "m.call",
device_id: this.deviceId,
expires,
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
@@ -941,6 +971,10 @@ export class MembershipManager
this.logger.error("MembershipManager has an unknown state. Actions: ", actions);
return Status.Unknown;
}
public get probablyLeft(): boolean {
return this.state.probablyLeft;
}
}
function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
+6
View File
@@ -104,6 +104,12 @@ export interface IRTCNotificationContent extends RelationEvent {
"lifetime": number;
}
/**
* MSC4310 decline event content for `org.matrix.msc4310.rtc.decline`.
* Sent as a standard m.reference relation to an `org.matrix.msc4075.rtc.notification` event.
*/
export interface IRTCDeclineContent extends RelationEvent {}
export enum Status {
Disconnected = "Disconnected",
Connecting = "Connecting",
-10
View File
@@ -822,16 +822,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
return this._decryptionFailureReason;
}
/**
* True if this event is an encrypted event which we failed to decrypt, the receiver's device is unverified and
* the sender has disabled encrypting to unverified devices.
*
* @deprecated: Prefer `event.decryptionFailureReason === DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE`.
*/
public get isEncryptedDisabledForUnverifiedDevices(): boolean {
return this.decryptionFailureReason === DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE;
}
public shouldAttemptDecryption(): boolean {
if (this.isRedacted()) return false;
if (this.isBeingDecrypted()) return false;
+6 -36
View File
@@ -66,7 +66,6 @@ export type RoomMemberEventHandlerMap = {
* ```
* matrixClient.on("RoomMember.powerLevel", function(event, member){
* var newPowerLevel = member.powerLevel;
* var newNormPowerLevel = member.powerLevelNorm;
* });
* ```
*/
@@ -109,10 +108,6 @@ export class RoomMember extends TypedEventEmitter<RoomMemberEvent, RoomMemberEve
* The power level for this room member.
*/
public powerLevel = 0;
/**
* The normalised power level (0-100) for this room member.
*/
public powerLevelNorm = 0;
/**
* The User object for this room member, if one exists.
*/
@@ -226,43 +221,18 @@ export class RoomMember extends TypedEventEmitter<RoomMemberEvent, RoomMemberEve
}
/**
* Update this room member's power level event. May fire
* "RoomMember.powerLevel" if this event updates this member's power levels.
* @param powerLevelEvent - The `m.room.power_levels` event
* Update this room member's power level event. Will fire
* "RoomMember.powerLevel" if the new power level is different
* @param powerLevel - The power level of the room member.
*
* @remarks
* Fires {@link RoomMemberEvent.PowerLevel}
*/
public setPowerLevelEvent(powerLevelEvent: MatrixEvent): void {
if (powerLevelEvent.getType() !== EventType.RoomPowerLevels || powerLevelEvent.getStateKey() !== "") {
return;
}
const evContent = powerLevelEvent.getDirectionalContent();
let maxLevel = evContent.users_default || 0;
const users: { [userId: string]: number } = evContent.users || {};
Object.values(users).forEach((lvl: number) => {
maxLevel = Math.max(maxLevel, lvl);
});
public setPowerLevel(powerLevel: number, powerLevelEvent: MatrixEvent): void {
const oldPowerLevel = this.powerLevel;
const oldPowerLevelNorm = this.powerLevelNorm;
this.powerLevel = powerLevel;
if (users[this.userId] !== undefined && Number.isInteger(users[this.userId])) {
this.powerLevel = users[this.userId];
} else if (evContent.users_default !== undefined) {
this.powerLevel = evContent.users_default;
} else {
this.powerLevel = 0;
}
this.powerLevelNorm = 0;
if (maxLevel > 0) {
this.powerLevelNorm = (this.powerLevel * 100) / maxLevel;
}
// emit for changes in powerLevelNorm as well (since the app will need to
// redraw everyone's level if the max has changed)
if (oldPowerLevel !== this.powerLevel || oldPowerLevelNorm !== this.powerLevelNorm) {
if (oldPowerLevel !== this.powerLevel) {
this.updateModifiedTime();
this.emit(RoomMemberEvent.PowerLevel, powerLevelEvent, this);
}
+86 -12
View File
@@ -33,6 +33,7 @@ import { TypedReEmitter } from "../ReEmitter.ts";
import { M_BEACON, M_BEACON_INFO } from "../@types/beacon.ts";
import { KnownMembership } from "../@types/membership.ts";
import { type RoomJoinRulesEventContent } from "../@types/state_events.ts";
import { shouldUseHydraForRoomVersion } from "../utils/roomVersion.ts";
export interface IMarkerFoundOptions {
/** Whether the timeline was empty before the marker event arrived in the
@@ -173,6 +174,9 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
public readonly beacons = new Map<BeaconIdentifier, Beacon>();
private _liveBeaconIds: BeaconIdentifier[] = [];
// We only wants to print warnings about bad room state once.
private getVersionWarning = false;
/**
* Construct room state.
*
@@ -209,6 +213,22 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
this.updateModifiedTime();
}
/**
* Gets the version of the room
* @returns The version of the room
*/
public getRoomVersion(): string {
const createEvent = this.getStateEvents(EventType.RoomCreate, "");
if (!createEvent) {
if (!this.getVersionWarning) {
logger.warn("[getVersion] Room " + this.roomId + " does not have an m.room.create event");
this.getVersionWarning = true;
}
return "1";
}
return createEvent.getContent()["room_version"] ?? "1";
}
/**
* Returns the number of joined members in this room
* This method caches the result.
@@ -468,12 +488,20 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
return;
}
const members = Object.values(this.members);
const createEvent = this.getStateEvents(EventType.RoomCreate, "");
const creators = getCreators(this.getRoomVersion(), createEvent);
members.forEach((member) => {
// We only propagate `RoomState.members` event if the
// power levels has been changed
// large room suffer from large re-rendering especially when not needed
const oldLastModified = member.getLastModifiedTime();
member.setPowerLevelEvent(event);
if (createEvent) {
const pl = powerLevelForUserId(member.userId, event, creators);
member.setPowerLevel(pl, event);
}
if (oldLastModified !== member.getLastModifiedTime()) {
this.emit(RoomStateEvent.Members, event, this, member);
}
@@ -625,9 +653,16 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
private updateMember(member: RoomMember): void {
// this member may have a power level already, so set it.
const createEvent = this.getStateEvents(EventType.RoomCreate, "");
const pwrLvlEvent = this.getStateEvents(EventType.RoomPowerLevels, "");
if (pwrLvlEvent) {
member.setPowerLevelEvent(pwrLvlEvent);
if (pwrLvlEvent && createEvent) {
const powerLevel = powerLevelForUserId(
member.userId,
pwrLvlEvent,
getCreators(this.getRoomVersion(), createEvent),
);
member.setPowerLevel(powerLevel, pwrLvlEvent);
}
// blow away the sentinel which is now outdated
@@ -904,7 +939,6 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
let stateDefault = 0;
let eventsDefault = 0;
let powerLevel = 0;
if (powerLevelsEvent) {
powerLevels = powerLevelsEvent.getContent();
eventsLevels = powerLevels.events || {};
@@ -915,13 +949,6 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
stateDefault = 50;
}
const userPowerLevel = powerLevels.users && powerLevels.users[userId];
if (Number.isSafeInteger(userPowerLevel)) {
powerLevel = userPowerLevel!;
} else if (Number.isSafeInteger(powerLevels.users_default)) {
powerLevel = powerLevels.users_default!;
}
if (Number.isSafeInteger(powerLevels.events_default)) {
eventsDefault = powerLevels.events_default!;
}
@@ -931,7 +958,11 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
if (Number.isSafeInteger(eventsLevels[eventType])) {
requiredLevel = eventsLevels[eventType];
}
return powerLevel >= requiredLevel;
const roomMember = this.getMember(userId);
const userPowerLevel = roomMember?.powerLevel ?? 0;
return userPowerLevel >= requiredLevel;
}
/**
@@ -1106,3 +1137,46 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
}
}
}
/**
* Get the set of creator user IDs for a room: empty if the room is not a 'hydra' room, otherwise
* computed from the sender of the m.room.create event plus the additional_creators field.
* @param roomVersion The version of the room
* @param roomCreateEvent The m.room.create event for the room
* @returns A set of user IDs of the creators of the room.
*/
function getCreators(roomVersion: string, roomCreateEvent: MatrixEvent | null): Set<string> {
const creators = new Set<string>();
if (shouldUseHydraForRoomVersion(roomVersion) && roomCreateEvent) {
const roomCreateSender = roomCreateEvent.getSender();
if (roomCreateSender) creators.add(roomCreateSender);
const additionalCreators = roomCreateEvent.getDirectionalContent().additional_creators;
if (Array.isArray(additionalCreators)) additionalCreators.forEach((c) => creators.add(c));
}
return creators;
}
/**
*
* @param userId The user ID to compute the power level for
* @param powerLevelEvents The power level event for the room
* @param creators The set of creator user IDs for the room if the room is a 'hydra' room, otherwise the empty set.
*/
function powerLevelForUserId(userId: string, powerLevelEvent: MatrixEvent, creators: Set<string>): number {
if (creators.has(userId)) {
// As of "Hydra", If the user is a creator, they always have the highest power level
return Infinity;
} else {
const evContent = powerLevelEvent.getDirectionalContent();
const users: { [userId: string]: number } = evContent.users || {};
if (users[userId] !== undefined && Number.isInteger(users[userId])) {
return users[userId];
} else if (evContent.users_default !== undefined) {
return evContent.users_default;
} else {
return 0;
}
}
}
+2 -12
View File
@@ -374,7 +374,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
private heroes: Hero[] | null = null;
// flags to stop logspam about missing m.room.create events
private getTypeWarning = false;
private getVersionWarning = false;
private membersPromise?: Promise<boolean>;
// XXX: These should be read-only
@@ -505,7 +504,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
this.pendingEventList = [];
this.client.store.getPendingEvents(this.roomId).then((events) => {
const mapper = this.client.getEventMapper({
toDevice: false,
decrypt: false,
});
events.forEach(async (serializedEvent: Partial<IEvent>) => {
@@ -606,18 +604,10 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
/**
* Gets the version of the room
* @returns The version of the room, or null if it could not be determined
* @returns The version of the room
*/
public getVersion(): string {
const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, "");
if (!createEvent) {
if (!this.getVersionWarning) {
logger.warn("[getVersion] Room " + this.roomId + " does not have an m.room.create event");
this.getVersionWarning = true;
}
return "1";
}
return createEvent.getContent()["room_version"] ?? "1";
return this.currentState.getRoomVersion();
}
/**
+8 -3
View File
@@ -439,6 +439,13 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
* has been initialised properly.
*/
this.replayEvents?.push(event);
// For annotations (reactions), aggregate immediately (pre-init) to keep
// reaction counts/summary visible while the thread is still initialising.
// Only aggregate as child: parent aggregation is unnecessary here.
if (event.isRelation(RelationType.Annotation)) {
this.timelineSet.relations?.aggregateChildEvent(event, this.timelineSet);
}
} else {
// Case 2: this is happening later, and we have a timeline. In
// this case, these events might be out-of order.
@@ -465,10 +472,8 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
} else {
this.addEventToTimeline(event, toStartOfTimeline);
}
// Aggregation is handled by EventTimelineSet when inserting/adding.
}
// Apply annotations and replace relations to the relations of the timeline only
this.timelineSet.relations?.aggregateParentEvent(event);
this.timelineSet.relations?.aggregateChildEvent(event, this.timelineSet);
}
public async processEvent(event: Optional<MatrixEvent>): Promise<void> {
+5
View File
@@ -125,6 +125,8 @@ export const generateAuthorizationUrl = async (
* @param prompt - indicates to the OP which flow the user should see - eg login or registration
* See https://openid.net/specs/openid-connect-prompt-create-1_0.html#name-prompt-parameter
* @param urlState - value to append to the opaque state identifier to uniquely identify the callback
* @param loginHint - value to send as the `login_hint` to the OP, giving a hint about the login identifier the user might use to log in.
* See {@link https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest OIDC core 3.1.2.1}.
* @returns a Promise with the url as a string
*/
export const generateOidcAuthorizationUrl = async ({
@@ -136,6 +138,7 @@ export const generateOidcAuthorizationUrl = async ({
nonce,
prompt,
urlState,
loginHint,
}: {
clientId: string;
metadata: ValidatedAuthMetadata;
@@ -145,6 +148,7 @@ export const generateOidcAuthorizationUrl = async ({
nonce: string;
prompt?: string;
urlState?: string;
loginHint?: string;
}): Promise<string> => {
const scope = generateScope();
const oidcClient = new OidcClient({
@@ -163,6 +167,7 @@ export const generateOidcAuthorizationUrl = async ({
nonce,
prompt,
url_state: urlState,
login_hint: loginHint,
});
return request.url;
+18 -3
View File
@@ -326,12 +326,22 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
* Implementation of {@link CryptoBackend.maybeAcceptKeyBundle}.
*/
public async maybeAcceptKeyBundle(roomId: string, inviter: string): Promise<void> {
// TODO: retry this if it gets interrupted or it fails.
// 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.
// 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})`);
// Make sure we have an up-to-date idea of the inviter's cross-signing keys, so that we can check if the
// device that sent us the bundle data was correctly cross-signed.
//
// TODO: it would be nice to skip this step if we have an up-to-date copy of the inviter's cross-signing keys,
// but we don't have an easy way to check that. Possibly the rust side could trigger a key request and then
// block until it happens.
logger.info(`Checking inviter cross-signing keys`);
const request = this.olmMachine.queryKeysForUsers([new RustSdkCryptoJs.UserId(inviter)]);
await this.outgoingRequestProcessor.makeOutgoingRequest(request);
const bundleData = await this.olmMachine.getReceivedRoomKeyBundleData(
new RustSdkCryptoJs.RoomId(roomId),
new RustSdkCryptoJs.UserId(inviter),
@@ -1076,7 +1086,12 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
verificationMethodIdentifierToMethod(method),
);
// Get the request content to send to the DM room
const verificationEventContent: string = await userIdentity.verificationRequestContent(methods);
const verCont: string = await userIdentity.verificationRequestContent(methods);
// TODO: due to https://github.com/matrix-org/matrix-rust-sdk/issues/5643, we need to fix up the verification request content to include `msgtype`.
const verContObj = JSON.parse(verCont);
verContObj["msgtype"] = "m.key.verification.request";
const verificationEventContent: string = JSON.stringify(verContObj);
// Send the request content to send to the DM room
const eventId = await this.sendVerificationRequestContent(roomId, verificationEventContent);
+4 -1
View File
@@ -317,7 +317,10 @@ export class SyncAccumulator {
break;
case Category.Join:
if (this.inviteRooms[roomId]) {
if (this.knockRooms[roomId]) {
// delete knock state on join
delete this.knockRooms[roomId];
} else if (this.inviteRooms[roomId]) {
// (1)
// was previously invite, now join. We expect /sync to give
// the entire state and timeline on 'join', so delete previous
-12
View File
@@ -435,18 +435,6 @@ export function immediate(): Promise<void> {
export function isNullOrUndefined(val: any): boolean {
return val === null || val === undefined;
}
/**
* @deprecated use {@link PromiseWithResolvers} instead.
*/
export type IDeferred<T> = PromiseWithResolvers<T>;
/**
* Creates a deferred promise. This is a promise that can be resolved or rejected.
* @deprecated use {@link Promise.withResolvers} instead.
*/
export function defer<T = void>(): IDeferred<T> {
return Promise.withResolvers<T>();
}
export async function promiseMapSeries<T>(
promises: Array<T | Promise<T>>,
+35
View File
@@ -0,0 +1,35 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Room versions strings that we know about and do not use hydra semantics.
*/
const PRE_HYDRA_ROOM_VERSIONS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"];
/**
* Checks if the given room version is one where new "hydra" power level
* semantics (ie. room version 12 or later) should be used
* (see https://github.com/matrix-org/matrix-spec-proposals/pull/4289).
* This will return `false` for versions that are known to the js-sdk and
* do not use hydra: any room versions unknown to the js-sdk (experimental or
* otherwise) will cause the function to return true.
*
* @param roomVersion - The version of the room to check.
* @returns `true` if hydra semantics should be used for the room version, `false` otherwise.
*/
export function shouldUseHydraForRoomVersion(roomVersion: string): boolean {
return !PRE_HYDRA_ROOM_VERSIONS.includes(roomVersion);
}
+415 -365
View File
File diff suppressed because it is too large Load Diff