Compare commits

..

36 Commits

Author SHA1 Message Date
RiotRobot d53d2dff45 v37.3.0-rc.0 2025-04-01 12:28:09 +00:00
renovate[bot] 3657eb61d8 Update dependency matrix-widget-api to v1.13.1 (#4689)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-31 12:52:27 +00:00
RiotRobot 1def88eb35 Merge branch 'master' into develop 2025-03-25 14:42:58 +00:00
RiotRobot 6f9e68c8f3 v37.2.0 2025-03-25 14:42:20 +00:00
Timo 5a65c8436d MatrixRTC MembershipManger: remove redundant sendDelayedEventAction and expose status (#4747)
* Remove redundant sendDelayedEventAction
We do already have the state `hasMemberEvent` that allows to distinguish the two cases. No need to create two dedicated actions.

* fix missing return

* Make membership manager an event emitter to inform about status updates.
 - deprecate isJoined (replaced by isActivated)
 - move Interface types to types.ts

* add tests for status updates.

* lint

* test "reschedules delayed leave event" in case the delayed event gets canceled

* review

* fix types

* prettier

* fix legacy membership manager
2025-03-25 12:49:47 +00:00
Michael Telatynski 2090319bdd Abstract logout-causing error type from tokenRefreshFunction calls (#4765)
* Abstract logout-causing error type from tokenRefreshFunction calls

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

* Add test

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-25 10:31:58 +00:00
renovate[bot] f4de8837fd Update dependency typedoc to ^0.28.0 (#4759)
* Update dependency typedoc to ^0.28.0

* Upgrade typedoc-plugin-missing-exports for compatibility

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

* Upgrade typedoc-plugin-missing-exports for compatibility

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

* Bump typedoc

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-21 13:36:35 +00:00
Michael Telatynski 1e92c13a75 Improve PushProcessor::getPushRuleGlobRegex (#4764)
* Improve PushProcessor::getPushRuleGlobRegex

Fix cache key not taking non-pattern parameters into account
Use lookarounds to ensure the word boundary isn't treated as part of the match

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

* Add tests

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-21 10:57:14 +00:00
Michael Telatynski 8061fa924d Export push processor & method for converting matrix glob to regexp (#4763)
* Export push processor method for converting matrix glob to regexp

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

* Export pushProcessor from MatrixClient

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

* Add capturing group around pattern match

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

* Improve comment

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-21 09:11:04 +00:00
m004 0760c5f64c Add authenticated media parameter to getMediaConfig (#4762)
* feat(client): Add authenticated media parameter to getMediaConfig

* test(client): add tests for mediaconfig

---------

Co-authored-by: Maurizio <maurizio-noah.abbruzzo-santiago@t-systems.com>
2025-03-20 16:16:11 +00:00
Richard van der Hoff e2bdb2f057 Rust crypto: set a timeout on outgoing HTTP requests (#4761) 2025-03-19 16:37:52 +00:00
David Baker fd47a189e0 Switch sliding sync support to simplified sliding sync (#4400)
* Switch sliding sync support to simplified sliding sync

Experimental PR to test js-sdk with simlified sliding sync.

This does not maintain support for regulaer sliding sync.

* Remove txn_id handling, ensure we always resend when req params change

* Fix some tests

* Fix remaining tests

* Mark TODOs on tests which need to die

* Linting

* Make comments lie less

* void

* Always sent full extension request

* Fix test

* Remove usage of deprecated field

* Hopefully fix DM names

* Refactor how heroes are handled in Room

* Fix how heroes work

* Linting

* Ensure that when SSS omits heroes we don't forget we had heroes

Otherwise when the room next appears the name/avatar reset to
'Empty Room' with no avatar.

* Check the right flag when doing timeline trickling

* Also change when the backpagination token is set

* Remove list ops and server-provided sort positions

SSS doesn't have them.

* Linting

* Add Room.bumpStamp

* Update crypto wasm lib

For new functions

* Add performance logging

* Fix breaking change in crypto wasm v8

* Update crypto wasm for breaking changes

See https://github.com/matrix-org/matrix-rust-sdk-crypto-wasm/releases/tag/v8.0.0
for how this was mapped from the previous API.

* Mark all tracked users as dirty on expired SSS connections

See https://github.com/matrix-org/matrix-rust-sdk/pull/3965 for
more information. Requires `Extension.onRequest` to be `async`.

* add ts extension

* Fix typedoc ref

* Add method to interface

* Don't force membership to invite

The membership was set correctly from the stripped state anyway so
this was redundant and was breaking rooms where we'd knocked.

* Missed merge

* Type import

* Make coverage happier

* More test coverage

* Grammar & formatting

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

* Remove markAllTrackedUsersAsDirty from crypto API

Not sure why this was in there, seems like it just needed to be in
crypto sync callbacks, which it already was.

* Remove I from interface

* API doc

* Move Hero definition to room-summary

* make comment more specific

* Move internal details into room.ts

and make the comment a proper tsdoc comment

* Use terser arrow function syntax

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

* Move comment to where we do the lookup

* Clarify comment

also prettier says hi

* Add comment

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

* Add tsdoc

explaining that the summary event will be modified

* more comment

* Remove unrelated changes

* Add docs & make fields optional

* Type import

* Clarify sync versions

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

* Make tsdoc comment & add info on when it's used.

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

* Rephrase comment

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

* Prettier

* Only fetch member for hero in legacy sync mode

* Split out a separate method to set SSS room summary

Rather than trying to fudge up an object that looked enough like the
old one that we could pass it in.

* Type import

* Make link work

* Nope, linter treats it as an unused import

* Add link the other way

* Add more detail to doc

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

* Remove unnecessary cast

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

* Remove length > 0 check

as it wasn't really necessary and may cause heroes not to be cleared?

* Doc params

* Remove unnecessary undefined comparison

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

* Put the comparison back

as it's necessary to stop typescript complaining

* Fix comment

* Fix comment

---------

Co-authored-by: Kegan Dougal <7190048+kegsay@users.noreply.github.com>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2025-03-18 17:23:45 +00:00
renovate[bot] c233334f27 Update all non-major dependencies (#4758)
* Update all non-major dependencies

* Hold back eslint-plugin-matrix-org

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-18 14:20:50 +00:00
renovate[bot] 029ab5f109 Update babel monorepo to v7.26.10 (#4755)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-18 13:59:37 +00:00
renovate[bot] 366f686827 Update guibranco/github-status-action-v2 digest to fe98467 (#4754)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-18 13:53:17 +00:00
renovate[bot] 26387287af Update typescript-eslint monorepo to v8.26.1 (#4757)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-18 13:47:00 +00:00
renovate[bot] ba0bbe1cba Update dependency eslint-import-resolver-typescript to v4 (#4760)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-18 13:43:41 +00:00
renovate[bot] 3c37d7b0fb Update dependency @types/node to v18.19.80 (#4756)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-18 13:42:40 +00:00
RiotRobot 097d645c19 v37.2.0-rc.0 2025-03-18 13:24:18 +00:00
Will Hunt e62aabccd9 Add reportRoom API (#4753)
* Add reportRoom

* add test

* fixdoc

* fix doc again

* Add docs for matrix version.

* lint
2025-03-17 15:03:54 +00:00
Michael Telatynski 0f3bcf3736 Allow port differing in OIDC dynamic registration URIs (#4749)
As per discussion and amendment to https://github.com/matrix-org/matrix-spec-proposals/pull/2966

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-17 08:39:00 +00:00
Timo 8395919f0f MatrixRTC: Fix running not representing what we need from isJoined in EC (#4752)
* Fix running != isJoined
EC expects isJoined to represent if we should be in joined state or not. It does not correlate to what our actual state of the scheduler is. We used the scheduler running state before but on leave the running state will stay true until we successfully updated the room state.
EC expects isJoined to immediately be false.

This introduces a member variable `activated` that represents if the MemberhsipManager is trying to connect or trying to disconnect independent on the current state.

* simplify catch finally blocks
2025-03-13 17:36:22 +00:00
renovate[bot] 6cdc68d19d Update dependency @babel/runtime to v7.26.10 [SECURITY] (#4751)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-13 16:24:04 +00:00
Michael Telatynski c879258545 Fix package.json repository.url (#4750)
Using `npm pkg fix`

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-13 16:04:58 +00:00
Michael Telatynski b14cc82682 OIDC: only pass logo_uri, policy_uri, tos_uri if they conform to "common base" (#4748)
* OIDC: only pass logo_uri, policy_uri, tos_uri if they conform to "common base"

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-03-13 14:47:09 +00:00
Timo 9f9be701e7 MatrixRTC: New membership manager (#4726)
* WIP doodles on MembershipManager test cases

* .

* initial membership manager test setup.

* Updates from discussion

* revert renaming comments

* remove unused import

* fix leave delayed event resend test.
It was missing a flush.

* comment out and remove unused variables

* es lint

* use jsdom instead of node test environment

* remove unused variables

* remove unused export

* temp

* review

* fixup tests

* more review

* remove wait for expect dependency

* temp

* fix wrong mocked meberhsip template

* rename MembershipManager -> LegacyMembershipManager
And remove the IMembershipManager from it

* Add new memberhsip manager

* fix tests to be compatible with old and new membership manager

* Comment cleanup

* Allow join to throw
 - Add tests for throwing cases
 - Fixs based on tests

* introduce membershipExpiryTimeoutSlack

* more detailed comments and cleanup

* warn if slack is misconfigured and use default values instead

* fix action resets.

* flatten MembershipManager.spec.ts

* rename testEnvironment to memberManagerTestEnvironment

* allow configuring Legacy manager in the matrixRTC session

* deprecate LegacyMembershipManager

* remove usage of waitForExpect

* flatten tests and add comments

* clean up leave logic branch

* add more leave test cases

* use defer

* review ("Some minor tidying things for now.")

* add onError for join method and cleanup

* use pop instead of filter

* fixes

* simplify error handling and MembershipAction
Only use one membership action enum

* Add diagram

* fix new error api in rtc session

* fix up retry counter

* fix lints

* make unrecoverable errors more explicit

* fix tests

* Allow multiple retries on the rtc state event http requests.

* use then catch for startup

* no try catch 1

* update expire headroom logic
transition from try catch to .then .catch

* replace flushPromise with advanceTimersByTimeAsync

* fix leaving special cases

* more unrecoverable errors special cases

* move to MatrixRTCSessionManager logger

* add state reset and add another unhandleable error
The error occurs if we want to cancel the delayed event we still have an id for but get a non expected error.

* missed review fixes

* remove @jest/environment dependency

* Cleanup awaits and Make mock types more correct.
Make every mock return a Promise if the real implementation does return a pormise.

* remove flush promise dependency

* fix not recreating default state on reset
This broke all tests since we only created the state once and than passed by ref

* Use per action rate limit and retry counter
There can be multiple retries at once so we need to store counters per action
e.g. the send update membership and the restart delayed could be rate limited at the same time.

* add linting to matrixrtc tests

* Add fix async lints and use matrix rtc logger for test environment.

* prettier

* review step 1

* change to MatrixRTCSession logger

* review step 2

* make LoopHandler Private

* update config to use NewManager wording

* emit error on rtc session if the membership manager encounters one

* network error and throw refactor

* make accessing the full room deprecated

* remove deprecated usage of full room

* Clean up the deprecation

* add network error handler and cleanup

* better logging, another test, make maximumNetworkErrorRetryCount configurable

* more logging & refactor leave promise

* add ConnectionError as possible retry cause

* Make it work in embedded mode with a server that does not support delayed events

* review iteration 1

* review iteration 2

* first step in improving widget error handling

* make the embedded client throw ConnectionErrors where desired.

* fix tests

* delayed event sending widget mode stop gap fix.

* improve comment

* fix unrecoverable error joinState (and add JoinStateChanged) emission.

* check that we do not add multipe sendFirstDelayed Events

* also check insertions queue

* always log "Missing own membership: force re-join"

* Do not update the membership if we are in any (a later) state of sending our own state.
The scheduled states MembershipActionType.SendFirstDelayedEvent and MembershipActionType.SendJoinEvent both imply that we are already trying to send our own membership state event.

* make leave reset actually stop the manager.
The reset case was not covered properly. There are cases where it is not allowed to add additional events after a reset and cases where we want to add more events after the reset. We need to allow this as a reset property.

* fix tests (and implementation)

* Allow MembershipManger to be set at runtime via JoinConfig.membershipManagerFactory

* Map actions into status as a sanity check

* Log status change after applying actions

* Add todo

* Cleanup

* Log transition from earlier status

* remove redundant status implementation
also add TODO comment to not forget about this.

* More cleanup

* Consider insertions in status()

* Log duration for emitting MatrixRTCSessionEvent.MembershipsChanged

* add another valid condition for connected

* some TODO cleanup

* review add warning when using addAction while the scheduler is not running.

* es lint

* refactor to return based handler approach (remove insertions array)

* refactor: Move action scheduler

* refactor: move different handler cases into separate functions

* linter

* review: delayed events endpoint error

* review

* Suggestions from pair review

* resetState is actually only used internally

* Revert "resetState is actually only used internally"

This reverts commit 6af4730919ec07ce9aaad8de35c27ac6b98a3019.

* refactor: running is part of the scheduler (not state)

* refactor: move everything state related from schduler to manager.

* review

* Update src/matrixrtc/NewMembershipManager.ts

Co-authored-by: Hugh Nimmo-Smith <hughns@users.noreply.github.com>

* review

* public -> private + missed review fiexes (comment typos)

---------

Co-authored-by: Hugh Nimmo-Smith <hughns@matrix.org>
Co-authored-by: Hugh Nimmo-Smith <hughns@users.noreply.github.com>
2025-03-11 17:49:01 +00:00
RiotRobot f552370c26 Merge branch 'master' into develop 2025-03-11 14:34:48 +00:00
David Baker db7e3e3cf3 Add disableKeyStorage() to crypto API (#4742)
* Add disableKeyStorage() to crypto API

As an all-in-one method for deleting all server side key storage on
the user's account (as the doc hopefully explains).

* Add test

* const

* Can't be disabled here
2025-03-06 11:16:28 +00:00
renovate[bot] 3e512711d7 Update matrix-org/sonarcloud-workflow-action action to v4 (#4741)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-04 14:45:54 +00:00
renovate[bot] f636976f15 Update dependency typedoc-plugin-mdn-links to v5 (#4740)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-04 14:11:26 +00:00
renovate[bot] 905df2f7ca Update dependency @stylistic/eslint-plugin to v4 (#4738)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-04 14:11:22 +00:00
renovate[bot] c1c473e6c6 Update dependency typescript to v5.8.2 (#4736)
* Update dependency typescript to v5.8.2

* Improve types

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-04 14:05:14 +00:00
renovate[bot] 4835ca61ec Update guibranco/github-status-action-v2 digest to 5ef6e17 (#4733)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-04 14:02:32 +00:00
renovate[bot] abdc8b2c2e Update typescript-eslint monorepo to v8.25.0 (#4737)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-04 13:39:36 +00:00
renovate[bot] 89fe90d27c Update typedoc (#4735)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-04 13:39:05 +00:00
renovate[bot] 86e0eb11cc Update dependency @types/node to v18.19.78 (#4734)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-04 13:38:30 +00:00
40 changed files with 2581 additions and 1546 deletions
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
steps:
# We create the status here and then update it to success/failure in the `report` stage
# This provides an easy link to this workflow_run from the PR before Sonarcloud is done.
- uses: guibranco/github-status-action-v2@7ca807c2ba3401be532d29a876b93262108099fb
- uses: guibranco/github-status-action-v2@fe98467f9071758c7fc214af9dbac7f301bd23d4
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: pending
@@ -75,7 +75,7 @@ jobs:
- name: "🩻 SonarCloud Scan"
id: sonarcloud
uses: matrix-org/sonarcloud-workflow-action@v3.3
uses: matrix-org/sonarcloud-workflow-action@v4.0
# workflow_run fails report against the develop commit always, we don't want that for PRs
continue-on-error: ${{ github.event.workflow_run.head_branch != 'develop' }}
with:
@@ -87,7 +87,7 @@ jobs:
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.SONAR_TOKEN }}
- uses: guibranco/github-status-action-v2@7ca807c2ba3401be532d29a876b93262108099fb
- uses: guibranco/github-status-action-v2@fe98467f9071758c7fc214af9dbac7f301bd23d4
if: always()
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -116,7 +116,7 @@ jobs:
steps:
- name: Skip SonarCloud on merge queues
if: env.ENABLE_COVERAGE == 'false'
uses: guibranco/github-status-action-v2@7ca807c2ba3401be532d29a876b93262108099fb
uses: guibranco/github-status-action-v2@fe98467f9071758c7fc214af9dbac7f301bd23d4
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
+14
View File
@@ -1,3 +1,17 @@
Changes in [37.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.2.0) (2025-03-25)
==================================================================================================
## ✨ Features
* Add reportRoom API ([#4753](https://github.com/matrix-org/matrix-js-sdk/pull/4753)). Contributed by @Half-Shot.
* MatrixRTC: New membership manager ([#4726](https://github.com/matrix-org/matrix-js-sdk/pull/4726)). Contributed by @toger5.
* Add disableKeyStorage() to crypto API ([#4742](https://github.com/matrix-org/matrix-js-sdk/pull/4742)). Contributed by @dbkr.
## 🐛 Bug Fixes
* Allow port differing in OIDC dynamic registration URIs ([#4749](https://github.com/matrix-org/matrix-js-sdk/pull/4749)). Contributed by @t3chguy.
* OIDC: only pass logo\_uri, policy\_uri, tos\_uri if they conform to "common base" ([#4748](https://github.com/matrix-org/matrix-js-sdk/pull/4748)). Contributed by @t3chguy.
Changes in [37.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.1.0) (2025-03-11)
==================================================================================================
## 🦖 Deprecations
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "37.1.0",
"version": "37.3.0-rc.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=20.0.0"
@@ -26,7 +26,7 @@
},
"repository": {
"type": "git",
"url": "https://github.com/matrix-org/matrix-js-sdk"
"url": "git+https://github.com/matrix-org/matrix-js-sdk.git"
},
"keywords": [
"matrix-org"
@@ -82,7 +82,7 @@
"@babel/preset-typescript": "^7.12.7",
"@casualbot/jest-sonar-reporter": "2.2.7",
"@peculiar/webcrypto": "^1.4.5",
"@stylistic/eslint-plugin": "^3.0.0",
"@stylistic/eslint-plugin": "^4.0.0",
"@types/content-type": "^1.1.5",
"@types/debug": "^4.1.7",
"@types/jest": "^29.0.0",
@@ -97,11 +97,11 @@
"eslint": "8.57.1",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^10.0.0",
"eslint-import-resolver-typescript": "^3.5.1",
"eslint-import-resolver-typescript": "^4.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^28.0.0",
"eslint-plugin-jsdoc": "^50.0.0",
"eslint-plugin-matrix-org": "^2.1.0",
"eslint-plugin-matrix-org": "2.1.0",
"eslint-plugin-n": "^14.0.0",
"eslint-plugin-tsdoc": "^0.4.0",
"eslint-plugin-unicorn": "^56.0.0",
@@ -117,13 +117,13 @@
"lint-staged": "^15.0.2",
"matrix-mock-request": "^2.5.0",
"node-fetch": "^2.7.0",
"prettier": "3.5.1",
"prettier": "3.5.3",
"rimraf": "^6.0.0",
"ts-node": "^10.9.2",
"typedoc": "^0.27.0",
"typedoc": "^0.28.1",
"typedoc-plugin-coverage": "^3.0.0",
"typedoc-plugin-mdn-links": "^4.0.0",
"typedoc-plugin-missing-exports": "^3.0.0",
"typedoc-plugin-mdn-links": "^5.0.0",
"typedoc-plugin-missing-exports": "^4.0.0",
"typescript": "^5.4.2"
},
"@casualbot/jest-sonar-reporter": {
+24
View File
@@ -157,6 +157,30 @@ describe("MatrixClient", function () {
});
});
describe("mediaConfig", function () {
it("should get media config on unauthenticated media call", async () => {
httpBackend.when("GET", "/_matrix/media/v3/config").respond(200, '{"m.upload.size": 50000000}', true);
const prom = client.getMediaConfig();
httpBackend.flushAllExpected();
expect((await prom)["m.upload.size"]).toEqual(50000000);
});
it("should get media config on authenticated media call", async () => {
httpBackend
.when("GET", "/_matrix/client/v1/media/config")
.respond(200, '{"m.upload.size": 50000000}', true);
const prom = client.getMediaConfig(true);
httpBackend.flushAllExpected();
expect((await prom)["m.upload.size"]).toEqual(50000000);
});
});
describe("joinRoom", function () {
it("should no-op given the ID of a room you've already joined", async () => {
const roomId = "!foo:bar";
+27 -15
View File
@@ -649,11 +649,13 @@ describe("SlidingSyncSdk", () => {
ext = findExtension("e2ee");
});
it("gets enabled on the initial request only", () => {
expect(ext.onRequest(true)).toEqual({
it("gets enabled all the time", async () => {
expect(await ext.onRequest(true)).toEqual({
enabled: true,
});
expect(await ext.onRequest(false)).toEqual({
enabled: true,
});
expect(ext.onRequest(false)).toEqual(undefined);
});
it("can update device lists", () => {
@@ -695,11 +697,13 @@ describe("SlidingSyncSdk", () => {
ext = findExtension("account_data");
});
it("gets enabled on the initial request only", () => {
expect(ext.onRequest(true)).toEqual({
it("gets enabled all the time", async () => {
expect(await ext.onRequest(true)).toEqual({
enabled: true,
});
expect(await ext.onRequest(false)).toEqual({
enabled: true,
});
expect(ext.onRequest(false)).toEqual(undefined);
});
it("processes global account data", async () => {
@@ -823,8 +827,12 @@ describe("SlidingSyncSdk", () => {
ext = findExtension("to_device");
});
it("gets enabled with a limit on the initial request only", () => {
const reqJson: any = ext.onRequest(true);
it("gets enabled all the time", async () => {
let reqJson: any = await ext.onRequest(true);
expect(reqJson.enabled).toEqual(true);
expect(reqJson.limit).toBeGreaterThan(0);
expect(reqJson.since).toBeUndefined();
reqJson = await ext.onRequest(false);
expect(reqJson.enabled).toEqual(true);
expect(reqJson.limit).toBeGreaterThan(0);
expect(reqJson.since).toBeUndefined();
@@ -835,7 +843,7 @@ describe("SlidingSyncSdk", () => {
next_batch: "12345",
events: [],
});
expect(ext.onRequest(false)).toEqual({
expect(await ext.onRequest(false)).toMatchObject({
since: "12345",
});
});
@@ -919,11 +927,13 @@ describe("SlidingSyncSdk", () => {
ext = findExtension("typing");
});
it("gets enabled on the initial request only", () => {
expect(ext.onRequest(true)).toEqual({
it("gets enabled all the time", async () => {
expect(await ext.onRequest(true)).toEqual({
enabled: true,
});
expect(await ext.onRequest(false)).toEqual({
enabled: true,
});
expect(ext.onRequest(false)).toEqual(undefined);
});
it("processes typing notifications", async () => {
@@ -1042,11 +1052,13 @@ describe("SlidingSyncSdk", () => {
ext = findExtension("receipts");
});
it("gets enabled on the initial request only", () => {
expect(ext.onRequest(true)).toEqual({
it("gets enabled all the time", async () => {
expect(await ext.onRequest(true)).toEqual({
enabled: true,
});
expect(await ext.onRequest(false)).toEqual({
enabled: true,
});
expect(ext.onRequest(false)).toEqual(undefined);
});
it("processes receipts", async () => {
+30 -747
View File
@@ -41,7 +41,7 @@ describe("SlidingSync", () => {
const selfUserId = "@alice:localhost";
const selfAccessToken = "aseukfgwef";
const proxyBaseUrl = "http://localhost:8008";
const syncUrl = proxyBaseUrl + "/_matrix/client/unstable/org.matrix.msc3575/sync";
const syncUrl = proxyBaseUrl + "/_matrix/client/unstable/org.matrix.simplified_msc3575/sync";
// assign client/httpBackend globals
const setupClient = () => {
@@ -103,8 +103,8 @@ describe("SlidingSync", () => {
};
const ext: Extension<any, any> = {
name: () => "custom_extension",
onRequest: (initial) => {
return { initial: initial };
onRequest: async (_) => {
return { initial: true };
},
onResponse: async (res) => {
return;
@@ -143,18 +143,16 @@ describe("SlidingSync", () => {
});
await httpBackend!.flushAllExpected();
// expect nothing but ranges and non-initial extensions to be sent
// expect all params to be sent TODO: check MSC4186
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.debug("got ", body);
expect(body.room_subscriptions).toBeFalsy();
expect(body.lists["a"]).toEqual({
ranges: [[0, 10]],
});
expect(body.lists["a"]).toEqual(listInfo);
expect(body.extensions).toBeTruthy();
expect(body.extensions["custom_extension"]).toEqual({ initial: false });
expect(body.extensions["custom_extension"]).toEqual({ initial: true });
expect(req.queryParams!["pos"]).toEqual("11");
})
.respond(200, function () {
@@ -332,6 +330,7 @@ describe("SlidingSync", () => {
await p;
});
// TODO: this does not exist in MSC4186
it("should be able to unsubscribe from a room", async () => {
httpBackend!
.when("POST", syncUrl)
@@ -389,18 +388,19 @@ describe("SlidingSync", () => {
[3, 5],
];
// request first 3 rooms
const listReq = {
ranges: [[0, 2]],
sort: ["by_name"],
timeline_limit: 1,
required_state: [["m.room.topic", ""]],
filters: {
is_dm: true,
},
};
let slidingSync: SlidingSync;
it("should be possible to subscribe to a list", async () => {
// request first 3 rooms
const listReq = {
ranges: [[0, 2]],
sort: ["by_name"],
timeline_limit: 1,
required_state: [["m.room.topic", ""]],
filters: {
is_dm: true,
},
};
slidingSync = new SlidingSync(proxyBaseUrl, new Map([["a", listReq]]), {}, client!, 1);
httpBackend!
.when("POST", syncUrl)
@@ -452,11 +452,6 @@ describe("SlidingSync", () => {
expect(slidingSync.getListData("b")).toBeNull();
const syncData = slidingSync.getListData("a")!;
expect(syncData.joinedCount).toEqual(500); // from previous test
expect(syncData.roomIndexToRoomId).toEqual({
0: roomA,
1: roomB,
2: roomC,
});
});
it("should be possible to adjust list ranges", async () => {
@@ -467,10 +462,9 @@ describe("SlidingSync", () => {
const body = req.data;
logger.log("next ranges", body.lists["a"].ranges);
expect(body.lists).toBeTruthy();
expect(body.lists["a"]).toEqual({
// only the ranges should be sent as the rest are unchanged and sticky
ranges: newRanges,
});
// list range should be changed
listReq.ranges = newRanges;
expect(body.lists["a"]).toEqual(listReq); // resend all values TODO: check MSC4186
})
.respond(200, {
pos: "b",
@@ -495,7 +489,9 @@ describe("SlidingSync", () => {
await httpBackend!.flushAllExpected();
await responseProcessed;
// setListRanges for an invalid list key returns an error
await expect(slidingSync.setListRanges("idontexist", newRanges)).rejects.toBeTruthy();
expect(() => {
slidingSync.setListRanges("idontexist", newRanges);
}).toThrow();
});
it("should be possible to add an extra list", async () => {
@@ -513,10 +509,7 @@ describe("SlidingSync", () => {
const body = req.data;
logger.log("extra list", body);
expect(body.lists).toBeTruthy();
expect(body.lists["a"]).toEqual({
// only the ranges should be sent as the rest are unchanged and sticky
ranges: newRanges,
});
expect(body.lists["a"]).toEqual(listReq); // resend all values TODO: check MSC4186
expect(body.lists["b"]).toEqual(extraListReq);
})
.respond(200, {
@@ -537,16 +530,6 @@ describe("SlidingSync", () => {
},
},
});
listenUntil(slidingSync, "SlidingSync.List", (listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("b");
expect(joinedCount).toEqual(50);
expect(roomIndexToRoomId).toEqual({
0: roomA,
1: roomB,
2: roomC,
});
return true;
});
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
@@ -554,706 +537,6 @@ describe("SlidingSync", () => {
await httpBackend!.flushAllExpected();
await responseProcessed;
});
it("should be possible to get list DELETE/INSERTs", async () => {
// move C (2) to A (0)
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "e",
lists: {
a: {
count: 500,
ops: [
{
op: "DELETE",
index: 2,
},
{
op: "INSERT",
index: 0,
room_id: roomC,
},
],
},
b: {
count: 50,
},
},
});
let listPromise = listenUntil(
slidingSync,
"SlidingSync.List",
(listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(500);
expect(roomIndexToRoomId).toEqual({
0: roomC,
1: roomA,
2: roomB,
});
return true;
},
);
let responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend!.flushAllExpected();
await responseProcessed;
await listPromise;
// move C (0) back to A (2)
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "f",
lists: {
a: {
count: 500,
ops: [
{
op: "DELETE",
index: 0,
},
{
op: "INSERT",
index: 2,
room_id: roomC,
},
],
},
b: {
count: 50,
},
},
});
listPromise = listenUntil(slidingSync, "SlidingSync.List", (listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(500);
expect(roomIndexToRoomId).toEqual({
0: roomA,
1: roomB,
2: roomC,
});
return true;
});
responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend!.flushAllExpected();
await responseProcessed;
await listPromise;
});
it("should ignore invalid list indexes", async () => {
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "e",
lists: {
a: {
count: 500,
ops: [
{
op: "DELETE",
index: 2324324,
},
],
},
b: {
count: 50,
},
},
});
const listPromise = listenUntil(
slidingSync,
"SlidingSync.List",
(listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(500);
expect(roomIndexToRoomId).toEqual({
0: roomA,
1: roomB,
2: roomC,
});
return true;
},
);
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend!.flushAllExpected();
await responseProcessed;
await listPromise;
});
it("should be possible to update a list", async () => {
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "g",
lists: {
a: {
count: 42,
ops: [
{
op: "INVALIDATE",
range: [0, 2],
},
{
op: "SYNC",
range: [0, 1],
room_ids: [roomB, roomC],
},
],
},
b: {
count: 50,
},
},
});
// update the list with a new filter
slidingSync.setList("a", {
filters: {
is_encrypted: true,
},
ranges: [[0, 100]],
});
const listPromise = listenUntil(
slidingSync,
"SlidingSync.List",
(listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(42);
expect(roomIndexToRoomId).toEqual({
0: roomB,
1: roomC,
});
return true;
},
);
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend!.flushAllExpected();
await responseProcessed;
await listPromise;
});
// this refers to a set of operations where the end result is no change.
it("should handle net zero operations correctly", async () => {
const indexToRoomId = {
0: roomB,
1: roomC,
};
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual(indexToRoomId);
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "f",
// currently the list is [B,C] so we will insert D then immediately delete it
lists: {
a: {
count: 500,
ops: [
{
op: "DELETE",
index: 2,
},
{
op: "INSERT",
index: 0,
room_id: roomA,
},
{
op: "DELETE",
index: 0,
},
],
},
b: {
count: 50,
},
},
});
const listPromise = listenUntil(
slidingSync,
"SlidingSync.List",
(listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(500);
expect(roomIndexToRoomId).toEqual(indexToRoomId);
return true;
},
);
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend!.flushAllExpected();
await responseProcessed;
await listPromise;
});
it("should handle deletions correctly", async () => {
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
0: roomB,
1: roomC,
});
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "g",
lists: {
a: {
count: 499,
ops: [
{
op: "DELETE",
index: 0,
},
],
},
b: {
count: 50,
},
},
});
const listPromise = listenUntil(
slidingSync,
"SlidingSync.List",
(listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(499);
expect(roomIndexToRoomId).toEqual({
0: roomC,
});
return true;
},
);
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend!.flushAllExpected();
await responseProcessed;
await listPromise;
});
it("should handle insertions correctly", async () => {
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
0: roomC,
});
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "h",
lists: {
a: {
count: 500,
ops: [
{
op: "INSERT",
index: 1,
room_id: roomA,
},
],
},
b: {
count: 50,
},
},
});
let listPromise = listenUntil(
slidingSync,
"SlidingSync.List",
(listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(500);
expect(roomIndexToRoomId).toEqual({
0: roomC,
1: roomA,
});
return true;
},
);
let responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend!.flushAllExpected();
await responseProcessed;
await listPromise;
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "h",
lists: {
a: {
count: 501,
ops: [
{
op: "INSERT",
index: 1,
room_id: roomB,
},
],
},
b: {
count: 50,
},
},
});
listPromise = listenUntil(slidingSync, "SlidingSync.List", (listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(501);
expect(roomIndexToRoomId).toEqual({
0: roomC,
1: roomB,
2: roomA,
});
return true;
});
responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend!.flushAllExpected();
await responseProcessed;
await listPromise;
slidingSync.stop();
});
// Regression test to make sure things like DELETE 0 INSERT 0 work correctly and we don't
// end up losing room IDs.
it("should handle insertions with a spurious DELETE correctly", async () => {
slidingSync = new SlidingSync(
proxyBaseUrl,
new Map([
[
"a",
{
ranges: [[0, 20]],
},
],
]),
{},
client!,
1,
);
// initially start with nothing
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "a",
lists: {
a: {
count: 0,
ops: [],
},
},
});
slidingSync.start();
await httpBackend!.flushAllExpected();
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({});
// insert a room
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "b",
lists: {
a: {
count: 1,
ops: [
{
op: "DELETE",
index: 0,
},
{
op: "INSERT",
index: 0,
room_id: roomA,
},
],
},
},
});
await httpBackend!.flushAllExpected();
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
0: roomA,
});
// insert another room
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "c",
lists: {
a: {
count: 1,
ops: [
{
op: "DELETE",
index: 1,
},
{
op: "INSERT",
index: 0,
room_id: roomB,
},
],
},
},
});
await httpBackend!.flushAllExpected();
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
0: roomB,
1: roomA,
});
// insert a final room
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "c",
lists: {
a: {
count: 1,
ops: [
{
op: "DELETE",
index: 2,
},
{
op: "INSERT",
index: 0,
room_id: roomC,
},
],
},
},
});
await httpBackend!.flushAllExpected();
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
0: roomC,
1: roomB,
2: roomA,
});
slidingSync.stop();
});
});
describe("transaction IDs", () => {
beforeAll(setupClient);
afterAll(teardownClient);
const roomId = "!foo:bar";
let slidingSync: SlidingSync;
// really this applies to them all but it's easier to just test one
it("should resolve modifyRoomSubscriptions after SlidingSync.start() is called", async () => {
const roomSubInfo = {
timeline_limit: 1,
required_state: [["m.room.name", ""]],
};
// add the subscription
slidingSync = new SlidingSync(proxyBaseUrl, new Map(), roomSubInfo, client!, 1);
// modification before SlidingSync.start()
const subscribePromise = slidingSync.modifyRoomSubscriptions(new Set([roomId]));
let txnId: string | undefined;
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.debug("got ", body);
expect(body.room_subscriptions).toBeTruthy();
expect(body.room_subscriptions[roomId]).toEqual(roomSubInfo);
expect(body.txn_id).toBeTruthy();
txnId = body.txn_id;
})
.respond(200, function () {
return {
pos: "aaa",
txn_id: txnId,
lists: {},
extensions: {},
rooms: {
[roomId]: {
name: "foo bar",
required_state: [],
timeline: [],
},
},
};
});
slidingSync.start();
await httpBackend!.flushAllExpected();
await subscribePromise;
});
it("should resolve setList during a connection", async () => {
const newList = {
ranges: [[0, 20]],
};
const promise = slidingSync.setList("a", newList);
let txnId: string | undefined;
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.debug("got ", body);
expect(body.room_subscriptions).toBeFalsy();
expect(body.lists["a"]).toEqual(newList);
expect(body.txn_id).toBeTruthy();
txnId = body.txn_id;
})
.respond(200, function () {
return {
pos: "bbb",
txn_id: txnId,
lists: { a: { count: 5 } },
extensions: {},
};
});
await httpBackend!.flushAllExpected();
await promise;
expect(txnId).toBeDefined();
});
it("should resolve setListRanges during a connection", async () => {
const promise = slidingSync.setListRanges("a", [[20, 40]]);
let txnId: string | undefined;
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.debug("got ", body);
expect(body.room_subscriptions).toBeFalsy();
expect(body.lists["a"]).toEqual({
ranges: [[20, 40]],
});
expect(body.txn_id).toBeTruthy();
txnId = body.txn_id;
})
.respond(200, function () {
return {
pos: "ccc",
txn_id: txnId,
lists: { a: { count: 5 } },
extensions: {},
};
});
await httpBackend!.flushAllExpected();
await promise;
expect(txnId).toBeDefined();
});
it("should resolve modifyRoomSubscriptionInfo during a connection", async () => {
const promise = slidingSync.modifyRoomSubscriptionInfo({
timeline_limit: 99,
});
let txnId: string | undefined;
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.debug("got ", body);
expect(body.room_subscriptions).toBeTruthy();
expect(body.room_subscriptions[roomId]).toEqual({
timeline_limit: 99,
});
expect(body.txn_id).toBeTruthy();
txnId = body.txn_id;
})
.respond(200, function () {
return {
pos: "ddd",
txn_id: txnId,
extensions: {},
};
});
await httpBackend!.flushAllExpected();
await promise;
expect(txnId).toBeDefined();
});
it("should reject earlier pending promises if a later transaction is acknowledged", async () => {
// i.e if we have [A,B,C] and see txn_id=C then A,B should be rejected.
const gotTxnIds: any[] = [];
const pushTxn = function (req: MockHttpBackend["requests"][0]) {
gotTxnIds.push(req.data.txn_id);
};
const failPromise = slidingSync.setListRanges("a", [[20, 40]]);
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "e" }); // missing txn_id
await httpBackend!.flushAllExpected();
const failPromise2 = slidingSync.setListRanges("a", [[60, 70]]);
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "f" }); // missing txn_id
await httpBackend!.flushAllExpected();
const okPromise = slidingSync.setListRanges("a", [[0, 20]]);
let txnId: string | undefined;
httpBackend!
.when("POST", syncUrl)
.check((req) => {
txnId = req.data.txn_id;
})
.respond(200, () => {
// include the txn_id, earlier requests should now be reject()ed.
return {
pos: "g",
txn_id: txnId,
};
});
await Promise.all([
expect(failPromise).rejects.toEqual(gotTxnIds[0]),
expect(failPromise2).rejects.toEqual(gotTxnIds[1]),
httpBackend!.flushAllExpected(),
okPromise,
]);
expect(txnId).toBeDefined();
});
it("should not reject later pending promises if an earlier transaction is acknowledged", async () => {
// i.e if we have [A,B,C] and see txn_id=B then C should not be rejected but A should.
const gotTxnIds: any[] = [];
const pushTxn = function (req: MockHttpBackend["requests"][0]) {
gotTxnIds.push(req.data?.txn_id);
};
const A = slidingSync.setListRanges("a", [[20, 40]]);
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "A" });
await httpBackend!.flushAllExpected();
const B = slidingSync.setListRanges("a", [[60, 70]]);
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "B" }); // missing txn_id
await httpBackend!.flushAllExpected();
// attach rejection handlers now else if we do it later Jest treats that as an unhandled rejection
// which is a fail.
const C = slidingSync.setListRanges("a", [[0, 20]]);
let pendingC = true;
C.finally(() => {
pendingC = false;
});
httpBackend!
.when("POST", syncUrl)
.check(pushTxn)
.respond(200, () => {
// include the txn_id for B, so C's promise is outstanding
return {
pos: "C",
txn_id: gotTxnIds[1],
};
});
await Promise.all([
expect(A).rejects.toEqual(gotTxnIds[0]),
httpBackend!.flushAllExpected(),
// A is rejected, see above
expect(B).resolves.toEqual(gotTxnIds[1]), // B is resolved
]);
expect(pendingC).toBe(true); // C is pending still
});
it("should do nothing for unknown txn_ids", async () => {
const promise = slidingSync.setListRanges("a", [[20, 40]]);
let pending = true;
promise.finally(() => {
pending = false;
});
let txnId: string | undefined;
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.debug("got ", body);
expect(body.room_subscriptions).toBeFalsy();
expect(body.lists["a"]).toEqual({
ranges: [[20, 40]],
});
expect(body.txn_id).toBeTruthy();
txnId = body.txn_id;
})
.respond(200, function () {
return {
pos: "ccc",
txn_id: "bogus transaction id",
lists: { a: { count: 5 } },
extensions: {},
};
});
await httpBackend!.flushAllExpected();
expect(txnId).toBeDefined();
expect(pending).toBe(true);
slidingSync.stop();
});
});
describe("custom room subscriptions", () => {
@@ -1543,7 +826,7 @@ describe("SlidingSync", () => {
const extPre: Extension<any, any> = {
name: () => preExtName,
onRequest: (initial) => {
onRequest: async (initial) => {
return onPreExtensionRequest(initial);
},
onResponse: (res) => {
@@ -1553,7 +836,7 @@ describe("SlidingSync", () => {
};
const extPost: Extension<any, any> = {
name: () => postExtName,
onRequest: (initial) => {
onRequest: async (initial) => {
return onPostExtensionRequest(initial);
},
onResponse: (res) => {
@@ -1568,7 +851,7 @@ describe("SlidingSync", () => {
const callbackOrder: string[] = [];
let extensionOnResponseCalled = false;
onPreExtensionRequest = () => {
onPreExtensionRequest = async () => {
return extReq;
};
onPreExtensionResponse = async (resp) => {
@@ -1608,7 +891,7 @@ describe("SlidingSync", () => {
});
it("should be able to send nothing in an extension request/response", async () => {
onPreExtensionRequest = () => {
onPreExtensionRequest = async () => {
return undefined;
};
let responseCalled = false;
@@ -1643,7 +926,7 @@ describe("SlidingSync", () => {
it("is possible to register extensions after start() has been called", async () => {
slidingSync.registerExtension(extPost);
onPostExtensionRequest = () => {
onPostExtensionRequest = async () => {
return extReq;
};
let responseCalled = false;
+20 -20
View File
@@ -49,26 +49,26 @@ const testOIDCToken = {
token_type: "Bearer",
};
class MockWidgetApi extends EventEmitter {
public start = jest.fn();
public requestCapability = jest.fn();
public requestCapabilities = jest.fn();
public requestCapabilityForRoomTimeline = jest.fn();
public requestCapabilityToSendEvent = jest.fn();
public requestCapabilityToReceiveEvent = jest.fn();
public requestCapabilityToSendMessage = jest.fn();
public requestCapabilityToReceiveMessage = jest.fn();
public requestCapabilityToSendState = jest.fn();
public requestCapabilityToReceiveState = jest.fn();
public requestCapabilityToSendToDevice = jest.fn();
public requestCapabilityToReceiveToDevice = jest.fn();
public start = jest.fn().mockResolvedValue(undefined);
public requestCapability = jest.fn().mockResolvedValue(undefined);
public requestCapabilities = jest.fn().mockResolvedValue(undefined);
public requestCapabilityForRoomTimeline = jest.fn().mockResolvedValue(undefined);
public requestCapabilityToSendEvent = jest.fn().mockResolvedValue(undefined);
public requestCapabilityToReceiveEvent = jest.fn().mockResolvedValue(undefined);
public requestCapabilityToSendMessage = jest.fn().mockResolvedValue(undefined);
public requestCapabilityToReceiveMessage = jest.fn().mockResolvedValue(undefined);
public requestCapabilityToSendState = jest.fn().mockResolvedValue(undefined);
public requestCapabilityToReceiveState = jest.fn().mockResolvedValue(undefined);
public requestCapabilityToSendToDevice = jest.fn().mockResolvedValue(undefined);
public requestCapabilityToReceiveToDevice = jest.fn().mockResolvedValue(undefined);
public sendRoomEvent = jest.fn(
(eventType: string, content: unknown, roomId?: string, delay?: number, parentDelayId?: string) =>
async (eventType: string, content: unknown, roomId?: string, delay?: number, parentDelayId?: string) =>
delay === undefined && parentDelayId === undefined
? { event_id: `$${Math.random()}` }
: { delay_id: `id-${Math.random()}` },
);
public sendStateEvent = jest.fn(
(
async (
eventType: string,
stateKey: string,
content: unknown,
@@ -80,17 +80,17 @@ class MockWidgetApi extends EventEmitter {
? { event_id: `$${Math.random()}` }
: { delay_id: `id-${Math.random()}` },
);
public updateDelayedEvent = jest.fn();
public sendToDevice = jest.fn();
public requestOpenIDConnectToken = jest.fn(() => {
public updateDelayedEvent = jest.fn().mockResolvedValue(undefined);
public sendToDevice = jest.fn().mockResolvedValue(undefined);
public requestOpenIDConnectToken = jest.fn(async () => {
return testOIDCToken;
return new Promise<IOpenIDCredentials>(() => {
return testOIDCToken;
});
});
public readStateEvents = jest.fn(() => []);
public getTurnServers = jest.fn(() => []);
public sendContentLoaded = jest.fn();
public readStateEvents = jest.fn(async () => []);
public getTurnServers = jest.fn(async () => []);
public sendContentLoaded = jest.fn().mockResolvedValue(undefined);
public transport = {
reply: jest.fn(),
+20
View File
@@ -607,6 +607,26 @@ describe("MatrixClient", function () {
});
});
describe("reportRoom", function () {
const roomId = "!foo:bar";
const reason = "rubbish room";
it("should send an invite HTTP POST", async function () {
httpLookups = [
{
method: "POST",
path: "/rooms/!foo%3Abar/report",
data: {},
expectBody: {
reason,
},
},
];
await client.reportRoom(roomId, reason);
expect(httpLookups.length).toEqual(0);
});
});
describe("sendEvent", () => {
const roomId = "!room:example.org";
const body = "This is the body";
+177 -18
View File
@@ -19,10 +19,17 @@ limitations under the License.
import { type MockedFunction, type Mock } from "jest-mock";
import { EventType, HTTPError, MatrixError, type Room } from "../../../src";
import { type Focus, type LivekitFocusActive, type SessionMembershipData } from "../../../src/matrixrtc";
import { LegacyMembershipManager } from "../../../src/matrixrtc/MembershipManager";
import { EventType, HTTPError, MatrixError, UnsupportedDelayedEventsEndpointError, type Room } from "../../../src";
import {
MembershipManagerEvent,
Status,
type Focus,
type LivekitFocusActive,
type SessionMembershipData,
} from "../../../src/matrixrtc";
import { LegacyMembershipManager } from "../../../src/matrixrtc/LegacyMembershipManager";
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
import { MembershipManager } from "../../../src/matrixrtc/NewMembershipManager";
import { defer } from "../../../src/utils";
function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>) {
@@ -33,6 +40,14 @@ function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>)
});
});
}
function waitForMockCallOnce(method: MockedFunction<any>, returnVal?: Promise<any>) {
return new Promise<void>((resolve) => {
method.mockImplementationOnce(() => {
resolve();
return returnVal ?? Promise.resolve();
});
});
}
function createAsyncHandle(method: MockedFunction<any>) {
const { reject, resolve, promise } = defer();
@@ -44,9 +59,10 @@ function createAsyncHandle(method: MockedFunction<any>) {
* Tests different MembershipManager implementations. Some tests don't apply to `LegacyMembershipManager`
* use !FailsForLegacy to skip those. See: testEnvironment for more details.
*/
describe.each([
{ TestMembershipManager: LegacyMembershipManager, description: "LegacyMembershipManager" },
// { TestMembershipManager: MembershipManager, description: "MembershipManager" },
{ TestMembershipManager: MembershipManager, description: "MembershipManager" },
])("$description", ({ TestMembershipManager }) => {
let client: MockClient;
let room: Room;
@@ -76,16 +92,16 @@ describe.each([
// There is no need to clean up mocks since we will recreate the client.
});
describe("isJoined()", () => {
describe("isActivated()", () => {
it("defaults to false", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
expect(manager.isJoined()).toEqual(false);
expect(manager.isActivated()).toEqual(false);
});
it("returns true after join()", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([]);
expect(manager.isJoined()).toEqual(true);
expect(manager.isActivated()).toEqual(true);
});
});
@@ -123,6 +139,23 @@ describe.each([
{},
"_@alice:example.org_AAAAAAA",
);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
});
it("reschedules delayed leave event if sending state cancels it", async () => {
const memberManager = new TestMembershipManager(undefined, room, client, () => undefined);
const waitForSendState = waitForMockCall(client.sendStateEvent);
const waitForUpdateDelaye = waitForMockCallOnce(
client._unstable_updateDelayedEvent,
Promise.reject(new MatrixError({ errcode: "M_NOT_FOUND" })),
);
memberManager.join([focus], focusActive);
await waitForSendState;
await waitForUpdateDelaye;
await jest.advanceTimersByTimeAsync(1);
// Once for the initial event and once because of the errcode: "M_NOT_FOUND"
// Different to "sends a membership event and schedules delayed leave when joining a call" where its only called once (1)
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
});
describe("does not prefix the state key with _ for rooms that support user-owned state events", () => {
@@ -244,7 +277,12 @@ describe.each([
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
delayedHandle.reject?.(Error("Server does not support the delayed events API"));
delayedHandle.reject?.(
new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
"sendDelayedStateEvent",
),
);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
});
it("does try to schedule a delayed leave event again if rate limited", async () => {
@@ -328,6 +366,7 @@ describe.each([
await jest.advanceTimersByTimeAsync(1);
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
await manager.leave();
// We send a normal leave event since we failed using updateDelayedEvent with the "send" action.
expect(client.sendStateEvent).toHaveBeenLastCalledWith(
room.roomId,
@@ -337,9 +376,9 @@ describe.each([
);
});
// FailsForLegacy because legacy implementation always sends the empty state event even though it isn't needed
it("does nothing if not joined !FailsForLegacy", async () => {
it("does nothing if not joined !FailsForLegacy", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
await manager.leave();
expect(async () => await manager.leave()).not.toThrow();
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
expect(client.sendStateEvent).not.toHaveBeenCalled();
});
@@ -470,10 +509,10 @@ describe.each([
// !FailsForLegacy because the expires logic was removed for the legacy call manager.
// Delayed events should replace it entirely but before they have wide adoption
// the expiration logic still makes sense.
// TODO: add git commit when we removed it.
it("extends `expires` when call still active !FailsForLegacy", async () => {
// TODO: Add git commit when we removed it.
async function testExpires(expire: number, headroom?: number) {
const manager = new TestMembershipManager(
{ membershipExpiryTimeout: 10_000 },
{ membershipExpiryTimeout: expire, membershipExpiryTimeoutHeadroom: headroom },
room,
client,
() => undefined,
@@ -482,16 +521,54 @@ describe.each([
await waitForMockCall(client.sendStateEvent);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
const sentMembership = (client.sendStateEvent as Mock).mock.calls[0][2] as SessionMembershipData;
expect(sentMembership.expires).toBe(10_000);
expect(sentMembership.expires).toBe(expire);
for (let i = 2; i <= 12; i++) {
await jest.advanceTimersByTimeAsync(10_000);
await jest.advanceTimersByTimeAsync(expire);
expect(client.sendStateEvent).toHaveBeenCalledTimes(i);
const sentMembership = (client.sendStateEvent as Mock).mock.lastCall![2] as SessionMembershipData;
expect(sentMembership.expires).toBe(10_000 * i);
expect(sentMembership.expires).toBe(expire * i);
}
}
it("extends `expires` when call still active !FailsForLegacy", async () => {
await testExpires(10_000);
});
it("extends `expires` using headroom configuration !FailsForLegacy", async () => {
await testExpires(10_000, 1_000);
});
});
describe("status updates", () => {
it("starts 'Disconnected' !FailsForLegacy", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
expect(manager.status).toBe(Status.Disconnected);
});
it("emits 'Connection' and 'Connected' after join !FailsForLegacy", async () => {
const handleDelayedEvent = createAsyncHandle(client._unstable_sendDelayedStateEvent);
const handleStateEvent = createAsyncHandle(client.sendStateEvent);
const manager = new TestMembershipManager({}, room, client, () => undefined);
expect(manager.status).toBe(Status.Disconnected);
const connectEmit = jest.fn();
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
manager.join([focus], focusActive);
expect(manager.status).toBe(Status.Connecting);
handleDelayedEvent.resolve();
await jest.advanceTimersByTimeAsync(1);
expect(connectEmit).toHaveBeenCalledWith(Status.Disconnected, Status.Connecting);
handleStateEvent.resolve();
await jest.advanceTimersByTimeAsync(1);
expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected);
});
it("emits 'Disconnecting' and 'Disconnected' after leave !FailsForLegacy", async () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
const connectEmit = jest.fn();
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
await manager.leave();
expect(connectEmit).toHaveBeenCalledWith(Status.Connected, Status.Disconnecting);
expect(connectEmit).toHaveBeenCalledWith(Status.Disconnecting, Status.Disconnected);
});
});
describe("server error handling", () => {
// Types of server error: 429 rate limit with no retry-after header, 429 with retry-after, 50x server error (maybe retry every second), connection/socket timeout
describe("retries sending delayed leave event", () => {
@@ -544,7 +621,7 @@ describe.each([
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
});
// FailsForLegacy as implementation does not re-check membership before retrying.
it("abandons retry loop if leave() was called !FailsForLegacy", async () => {
it("abandons retry loop if leave() was called before sending state event !FailsForLegacy", async () => {
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
const manager = new TestMembershipManager({}, room, client, () => undefined);
@@ -565,7 +642,6 @@ describe.each([
await manager.leave();
// Wait for all timers to be setup
// await flushPromises();
await jest.advanceTimersByTimeAsync(1000);
// No new events should have been sent:
@@ -603,4 +679,87 @@ describe.each([
});
});
});
describe("unrecoverable errors", () => {
// !FailsForLegacy because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
it("throws, when reaching maximum number of retries for initial delayed event creation !FailsForLegacy", async () => {
const delayEventSendError = jest.fn();
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED" },
429,
undefined,
undefined,
new Headers({ "Retry-After": "2" }),
),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive, delayEventSendError);
for (let i = 0; i < 10; i++) {
await jest.advanceTimersByTimeAsync(2000);
}
expect(delayEventSendError).toHaveBeenCalled();
});
// !FailsForLegacy because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
it("throws, when reaching maximum number of retries !FailsForLegacy", async () => {
const delayEventRestartError = jest.fn();
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue(
new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED" },
429,
undefined,
undefined,
new Headers({ "Retry-After": "1" }),
),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive, delayEventRestartError);
for (let i = 0; i < 10; i++) {
await jest.advanceTimersByTimeAsync(1000);
}
expect(delayEventRestartError).toHaveBeenCalled();
});
it("falls back to using pure state events when some error occurs while sending delayed events !FailsForLegacy", async () => {
const unrecoverableError = jest.fn();
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 601));
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive, unrecoverableError);
await waitForMockCall(client.sendStateEvent);
expect(unrecoverableError).not.toHaveBeenCalledWith();
expect(client.sendStateEvent).toHaveBeenCalled();
});
it("retries before failing in case its a network error !FailsForLegacy", async () => {
const unrecoverableError = jest.fn();
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 501));
const manager = new TestMembershipManager(
{ callMemberEventRetryDelayMinimum: 1000, maximumNetworkErrorRetryCount: 7 },
room,
client,
() => undefined,
);
manager.join([focus], focusActive, unrecoverableError);
for (let retries = 0; retries < 7; retries++) {
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(retries + 1);
await jest.advanceTimersByTimeAsync(1000);
}
expect(unrecoverableError).toHaveBeenCalled();
expect(unrecoverableError.mock.lastCall![0].message).toMatch(
"The MembershipManager shut down because of the end condition",
);
expect(client.sendStateEvent).not.toHaveBeenCalled();
});
it("falls back to using pure state events when UnsupportedDelayedEventsEndpointError encountered for delayed events !FailsForLegacy", async () => {
const unrecoverableError = jest.fn();
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive, unrecoverableError);
await jest.advanceTimersByTimeAsync(1);
expect(unrecoverableError).not.toHaveBeenCalled();
expect(client.sendStateEvent).toHaveBeenCalled();
});
});
});
+1 -1
View File
@@ -25,7 +25,7 @@ export const membershipTemplate: SessionMembershipData = {
call_id: "",
device_id: "AAAAAAA",
scope: "m.room",
focus_active: { type: "livekit", livekit_service_url: "https://lk.url" },
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
foci_preferred: [
{
livekit_alias: "!alias:something.org",
+26 -2
View File
@@ -29,9 +29,10 @@ describe("registerOidcClient()", () => {
redirectUris: [baseUrl],
clientName,
applicationType: "web",
tosUri: "http://tos-uri",
policyUri: "http://policy-uri",
tosUri: "https://just.testing/tos",
policyUri: "https://policy.just.testing",
contacts: ["admin@example.com"],
logoUri: `${baseUrl}:8443/logo.png`,
};
const dynamicClientId = "xyz789";
@@ -67,6 +68,9 @@ describe("registerOidcClient()", () => {
id_token_signed_response_alg: "RS256",
token_endpoint_auth_method: "none",
application_type: "web",
tos_uri: "https://just.testing/tos",
policy_uri: "https://policy.just.testing",
logo_uri: `${baseUrl}:8443/logo.png`,
}),
);
});
@@ -114,4 +118,24 @@ describe("registerOidcClient()", () => {
),
).rejects.toThrow(OidcError.DynamicRegistrationNotSupported);
});
it("should filter out invalid URIs", async () => {
fetchMockJest.post(delegatedAuthConfig.registration_endpoint!, {
status: 200,
body: JSON.stringify({ client_id: dynamicClientId }),
});
expect(
await registerOidcClient(delegatedAuthConfig, {
...metadata,
tosUri: "http://just.testing/tos",
policyUri: "https://policy-uri/",
}),
).toEqual(dynamicClientId);
expect(JSON.parse(fetchMockJest.mock.calls[0][1]!.body as string)).not.toEqual(
expect.objectContaining({
tos_uri: "http://just.testing/tos",
policy_uri: "https://policy-uri/",
}),
);
});
});
+23 -1
View File
@@ -20,7 +20,7 @@ limitations under the License.
import fetchMock from "fetch-mock-jest";
import { OidcTokenRefresher } from "../../../src";
import { OidcTokenRefresher, TokenRefreshLogoutError } from "../../../src";
import { logger } from "../../../src/logger";
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
@@ -266,5 +266,27 @@ describe("OidcTokenRefresher", () => {
refreshToken: "second-new-refresh-token",
});
});
it("should throw TokenRefreshLogoutError when expired", async () => {
fetchMock.post(
config.token_endpoint,
{
status: 400,
headers: {
"Content-Type": "application/json",
},
body: {
error: "invalid_grant",
error_description: "The provided access grant is invalid, expired, or revoked.",
},
},
{ overwriteRoutes: true },
);
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
await expect(refresher.doRefreshAccessToken("refresh-token")).rejects.toThrow(TokenRefreshLogoutError);
});
});
});
+38
View File
@@ -1,3 +1,19 @@
/*
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.
*/
import * as utils from "../test-utils/test-utils";
import { type IActionsObject, PushProcessor } from "../../src/pushprocessor";
import {
@@ -1004,3 +1020,25 @@ describe("rewriteDefaultRules", () => {
]);
});
});
describe("getPushRuleGlobRegex", () => {
it("should not confuse flags in cache", () => {
const pattern = "Test";
const regex1 = PushProcessor.getPushRuleGlobRegex(pattern, false, "i");
const regex2 = PushProcessor.getPushRuleGlobRegex(pattern, false, "g");
const regex3 = PushProcessor.getPushRuleGlobRegex(pattern, false, "i");
expect(regex1.flags).toBe("i");
expect(regex2.flags).toBe("g");
expect(regex1).not.toEqual(regex2);
expect(regex1).toEqual(regex3);
});
it("should not include word boundary in match", () => {
const pattern = "@room";
const regex = PushProcessor.getPushRuleGlobRegex(pattern, true);
const input = "Foo @room Bar";
expect(input.split(regex)).toEqual(["Foo ", "@room", " Bar"]);
});
});
+15
View File
@@ -1234,6 +1234,16 @@ describe("Room", function () {
expect(room.name).toEqual(nameB);
});
it("supports MSC4186 style heroes", () => {
const nameB = "Bertha Bobbington";
const nameC = "Clarissa Harissa";
addMember(userB, KnownMembership.Join, { name: nameB });
addMember(userC, KnownMembership.Join, { name: nameC });
room.setMSC4186SummaryData([{ user_id: userB }, { user_id: userC }], undefined, undefined);
room.recalculate();
expect(room.name).toEqual(`${nameB} and ${nameC}`);
});
it("reverts to empty room in case of self chat", function () {
room.setSummary({
"m.heroes": [],
@@ -4276,4 +4286,9 @@ describe("Room", function () {
expect(filteredEvents[0].getContent().body).toEqual("ev2");
});
});
it("saves and retrieves the bump stamp", () => {
room.setBumpStamp(123456789);
expect(room.getBumpStamp()).toEqual(123456789);
});
});
+37
View File
@@ -2318,6 +2318,43 @@ describe("RustCrypto", () => {
expect(dehydratedDeviceIsDeleted).toBeTruthy();
});
});
describe("disableKeyStorage", () => {
it("should disable key storage", async () => {
const secretStorage = {
getDefaultKeyId: jest.fn().mockResolvedValue("bloop"),
setDefaultKeyId: jest.fn(),
store: jest.fn(),
} as unknown as ServerSideSecretStorage;
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
let backupIsDeleted = false;
fetchMock.delete("path:/_matrix/client/v3/room_keys/version/1", () => {
backupIsDeleted = true;
return {};
});
let dehydratedDeviceIsDeleted = false;
fetchMock.delete("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", () => {
dehydratedDeviceIsDeleted = true;
return { device_id: "ADEVICEID" };
});
const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), undefined, undefined, secretStorage);
await rustCrypto.disableKeyStorage();
expect(secretStorage.store).toHaveBeenCalledWith("m.cross_signing.master", null);
expect(secretStorage.store).toHaveBeenCalledWith("m.cross_signing.self_signing", null);
expect(secretStorage.store).toHaveBeenCalledWith("m.cross_signing.user_signing", null);
expect(secretStorage.store).toHaveBeenCalledWith("m.megolm_backup.v1", null);
expect(secretStorage.store).toHaveBeenCalledWith("m.secret_storage.key.bloop", null);
expect(secretStorage.setDefaultKeyId).toHaveBeenCalledWith(null);
expect(backupIsDeleted).toBeTruthy();
expect(dehydratedDeviceIsDeleted).toBeTruthy();
});
});
});
/** Build a MatrixHttpApi instance */
+47 -10
View File
@@ -240,6 +240,7 @@ import {
validateAuthMetadataAndKeys,
} from "./oidc/index.ts";
import { type EmptyObject } from "./@types/common.ts";
import { UnsupportedDelayedEventsEndpointError } from "./errors.ts";
export type Store = IStore;
@@ -1233,7 +1234,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public canSupport = new Map<Feature, ServerSupport>();
// The pushprocessor caches useful things, so keep one and re-use it
protected pushProcessor = new PushProcessor(this);
public readonly pushProcessor = new PushProcessor(this);
// Promise to a response of the server's /versions response
// TODO: This should expire: https://github.com/matrix-org/matrix-js-sdk/issues/1020
@@ -2068,11 +2069,18 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* Get the config for the media repository.
*
* @param useAuthenticatedMedia - If true, the caller supports authenticated
* media and wants an authentication-required URL. Note that server support
* for authenticated media will *not* be checked - it is the caller's responsibility
* to do so before calling this function.
*
* @returns Promise which resolves with an object containing the config.
*/
public getMediaConfig(): Promise<IMediaConfig> {
return this.http.authedRequest(Method.Get, "/config", undefined, undefined, {
prefix: MediaPrefix.V3,
public getMediaConfig(useAuthenticatedMedia: boolean = false): Promise<IMediaConfig> {
const path = useAuthenticatedMedia ? "/media/config" : "/config";
return this.http.authedRequest(Method.Get, path, undefined, undefined, {
prefix: useAuthenticatedMedia ? ClientPrefix.V1 : MediaPrefix.V3,
});
}
@@ -3351,7 +3359,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
txnId?: string,
): Promise<SendDelayedEventResponse> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw Error("Server does not support the delayed events API");
throw new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
"sendDelayedEvent",
);
}
this.addThreadRelationIfNeeded(content, threadId, roomId);
@@ -3374,7 +3385,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
opts: IRequestOpts = {},
): Promise<SendDelayedEventResponse> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw Error("Server does not support the delayed events API");
throw new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
"sendDelayedStateEvent",
);
}
const pathParams = {
@@ -3398,7 +3412,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// eslint-disable-next-line
public async _unstable_getDelayedEvents(fromToken?: string): Promise<DelayedEventInfo> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw Error("Server does not support the delayed events API");
throw new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
"getDelayedEvents",
);
}
const queryDict = fromToken ? { from: fromToken } : undefined;
@@ -3420,7 +3437,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
requestOptions: IRequestOpts = {},
): Promise<EmptyObject> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw Error("Server does not support the delayed events API");
throw new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
"updateDelayedEvent",
);
}
const path = utils.encodeUri("/delayed_events/$delayId", {
@@ -8028,7 +8048,24 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
/**
* Fetches or paginates a room hierarchy as defined by MSC2946.
* Reports a room as inappropriate to the server, which may then notify the appropriate people.
*
* This API was introduced in Matrix v1.13.
*
* @param roomId - The room being reported.
* @param reason - The reason the room is being reported. May be blank.
* @returns Promise which resolves to an empty object if successful
*/
public reportRoom(roomId: string, reason: string): Promise<EmptyObject> {
const path = utils.encodeUri("/rooms/$roomId/report", {
$roomId: roomId,
});
return this.http.authedRequest(Method.Post, path, undefined, { reason });
}
/**
* Fetches or paginates a room hierarchy asmatrix-js-sdk/spec/unit/matrix-client.spec.ts defined by MSC2946.
* Falls back gracefully to sourcing its data from `getSpaceSummary` if this API is not yet supported by the server.
* @param roomId - The ID of the space-room to use as the root of the summary.
* @param limit - The maximum number of rooms to return per page.
@@ -8164,7 +8201,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const clientTimeout = req.clientTimeout;
delete req.clientTimeout;
return this.http.authedRequest<MSC3575SlidingSyncResponse>(Method.Post, "/sync", qps, req, {
prefix: "/_matrix/client/unstable/org.matrix.msc3575",
prefix: "/_matrix/client/unstable/org.matrix.simplified_msc3575",
baseUrl: proxyBaseUrl,
localTimeoutMs: clientTimeout,
abortSignal,
+9
View File
@@ -138,6 +138,15 @@ export interface SyncCryptoCallbacks {
* @param syncState - information about the completed sync.
*/
onSyncCompleted(syncState: OnSyncCompletedData): void;
/**
* Mark all tracked users' device lists as dirty.
*
* This method will cause additional `/keys/query` requests on the server, so should be used only
* when the client has desynced tracking device list deltas from the server.
* In MSC4186: Simplified Sliding Sync, this can happen when the server expires the connection.
*/
markAllTrackedUsersAsDirty(): Promise<void>;
}
/**
+10
View File
@@ -629,6 +629,16 @@ export interface CryptoApi {
*/
resetKeyBackup(): Promise<void>;
/**
* Disables server-side key storage and deletes server-side backups.
* * Deletes the current key backup version, if any (but not any previous versions).
* * Disables 4S, deleting the info for the default key, the default key pointer itself and any
* known 4S data (cross-signing keys and the megolm key backup key).
* * Deletes any dehydrated devices.
* * Sets the "m.org.matrix.custom.backup_disabled" account data flag to indicate that the user has disabled backups.
*/
disableKeyStorage(): Promise<void>;
/**
* Deletes the given key backup.
*
+62 -27
View File
@@ -50,12 +50,12 @@ import {
} from "./client.ts";
import { SyncApi, SyncState } from "./sync.ts";
import { SlidingSyncSdk } from "./sliding-sync-sdk.ts";
import { MatrixError } from "./http-api/errors.ts";
import { ConnectionError, MatrixError } from "./http-api/errors.ts";
import { User } from "./models/user.ts";
import { type Room } from "./models/room.ts";
import { type ToDeviceBatch, type ToDevicePayload } from "./models/ToDeviceMessage.ts";
import { MapWithDefault, recursiveMapToObject } from "./utils.ts";
import { type EmptyObject, TypedEventEmitter } from "./matrix.ts";
import { type EmptyObject, TypedEventEmitter, UnsupportedDelayedEventsEndpointError } from "./matrix.ts";
interface IStateEventRequest {
eventType: string;
@@ -358,13 +358,15 @@ export class RoomWidgetClient extends MatrixClient {
// Delayed event special case.
if (delayOpts) {
// TODO: updatePendingEvent for delayed events?
const response = await this.widgetApi.sendRoomEvent(
event.getType(),
content,
room.roomId,
"delay" in delayOpts ? delayOpts.delay : undefined,
"parent_delay_id" in delayOpts ? delayOpts.parent_delay_id : undefined,
);
const response = await this.widgetApi
.sendRoomEvent(
event.getType(),
content,
room.roomId,
"delay" in delayOpts ? delayOpts.delay : undefined,
"parent_delay_id" in delayOpts ? delayOpts.parent_delay_id : undefined,
)
.catch(timeoutToConnectionError);
return this.validateSendDelayedEventResponse(response);
}
@@ -374,7 +376,9 @@ export class RoomWidgetClient extends MatrixClient {
let response: ISendEventFromWidgetResponseData;
try {
response = await this.widgetApi.sendRoomEvent(event.getType(), content, room.roomId);
response = await this.widgetApi
.sendRoomEvent(event.getType(), content, room.roomId)
.catch(timeoutToConnectionError);
} catch (e) {
this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT);
throw e;
@@ -397,7 +401,9 @@ export class RoomWidgetClient extends MatrixClient {
content: any,
stateKey = "",
): Promise<ISendEventResponse> {
const response = await this.widgetApi.sendStateEvent(eventType, stateKey, content, roomId);
const response = await this.widgetApi
.sendStateEvent(eventType, stateKey, content, roomId)
.catch(timeoutToConnectionError);
if (response.event_id === undefined) {
throw new Error("'event_id' absent from response to an event request");
}
@@ -416,17 +422,22 @@ export class RoomWidgetClient extends MatrixClient {
stateKey = "",
): Promise<SendDelayedEventResponse> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw Error("Server does not support the delayed events API");
throw new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
"sendDelayedStateEvent",
);
}
const response = await this.widgetApi.sendStateEvent(
eventType,
stateKey,
content,
roomId,
"delay" in delayOpts ? delayOpts.delay : undefined,
"parent_delay_id" in delayOpts ? delayOpts.parent_delay_id : undefined,
);
const response = await this.widgetApi
.sendStateEvent(
eventType,
stateKey,
content,
roomId,
"delay" in delayOpts ? delayOpts.delay : undefined,
"parent_delay_id" in delayOpts ? delayOpts.parent_delay_id : undefined,
)
.catch(timeoutToConnectionError);
return this.validateSendDelayedEventResponse(response);
}
@@ -443,20 +454,25 @@ export class RoomWidgetClient extends MatrixClient {
// eslint-disable-next-line
public async _unstable_updateDelayedEvent(delayId: string, action: UpdateDelayedEventAction): Promise<EmptyObject> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw Error("Server does not support the delayed events API");
throw new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
"updateDelayedEvent",
);
}
await this.widgetApi.updateDelayedEvent(delayId, action);
await this.widgetApi.updateDelayedEvent(delayId, action).catch(timeoutToConnectionError);
return {};
}
public async sendToDevice(eventType: string, contentMap: SendToDeviceContentMap): Promise<EmptyObject> {
await this.widgetApi.sendToDevice(eventType, false, recursiveMapToObject(contentMap));
await this.widgetApi
.sendToDevice(eventType, false, recursiveMapToObject(contentMap))
.catch(timeoutToConnectionError);
return {};
}
public async getOpenIdToken(): Promise<IOpenIDToken> {
const token = await this.widgetApi.requestOpenIDConnectToken();
const token = await this.widgetApi.requestOpenIDConnectToken().catch(timeoutToConnectionError);
// the IOpenIDCredentials from the widget-api and IOpenIDToken form the matrix-js-sdk are compatible.
// we still recreate the token to make this transparent and catch'able by the linter in case the types change in the future.
return <IOpenIDToken>{
@@ -474,7 +490,9 @@ export class RoomWidgetClient extends MatrixClient {
contentMap.getOrCreate(userId).set(deviceId, payload);
}
await this.widgetApi.sendToDevice(eventType, false, recursiveMapToObject(contentMap));
await this.widgetApi
.sendToDevice(eventType, false, recursiveMapToObject(contentMap))
.catch(timeoutToConnectionError);
}
public async encryptAndSendToDevices(userDeviceInfoArr: OlmDevice[], payload: object): Promise<void> {
@@ -484,7 +502,9 @@ export class RoomWidgetClient extends MatrixClient {
contentMap.getOrCreate(userId).set(deviceId, payload);
}
await this.widgetApi.sendToDevice((payload as { type: string }).type, true, recursiveMapToObject(contentMap));
await this.widgetApi
.sendToDevice((payload as { type: string }).type, true, recursiveMapToObject(contentMap))
.catch(timeoutToConnectionError);
}
/**
@@ -505,7 +525,9 @@ export class RoomWidgetClient extends MatrixClient {
encrypted: boolean,
contentMap: SendToDeviceContentMap,
): Promise<void> {
await this.widgetApi.sendToDevice(eventType, encrypted, recursiveMapToObject(contentMap));
await this.widgetApi
.sendToDevice(eventType, encrypted, recursiveMapToObject(contentMap))
.catch(timeoutToConnectionError);
}
// Overridden since we get TURN servers automatically over the widget API,
@@ -670,3 +692,16 @@ function processAndThrow(error: unknown): never {
throw error;
}
}
/**
* This converts an "Request timed out" error from the PostmessageTransport into a ConnectionError.
* It either throws the original error or a new ConnectionError.
**/
function timeoutToConnectionError(error: unknown): never {
// TODO: this should not check on error.message but instead it should be a specific type
// error instanceof WidgetTimeoutError
if (error instanceof Error && error.message === "Request timed out") {
throw new ConnectionError("widget api timeout");
}
throw error;
}
+13
View File
@@ -52,3 +52,16 @@ export class ClientStoppedError extends Error {
super("MatrixClient has been stopped");
}
}
/**
* This error is thrown when the Homeserver does not support the delayed events enpdpoints.
*/
export class UnsupportedDelayedEventsEndpointError extends Error {
public constructor(
message: string,
public clientEndpoint: "sendDelayedEvent" | "updateDelayedEvent" | "sendDelayedStateEvent" | "getDelayedEvents",
) {
super(message);
this.name = "UnsupportedDelayedEventsEndpointError";
}
}
+14
View File
@@ -212,3 +212,17 @@ export class TokenRefreshError extends Error {
return "TokenRefreshError";
}
}
/**
* Construct a TokenRefreshError. This indicates that a request failed due to the token being expired,
* and attempting to refresh said token failed in a way indicative of token invalidation.
*/
export class TokenRefreshLogoutError extends Error {
public constructor(cause?: Error) {
super(cause?.message ?? "");
}
public get name(): string {
return "TokenRefreshLogoutError";
}
}
+7 -8
View File
@@ -18,12 +18,10 @@ limitations under the License.
* This is an internal module. See {@link MatrixHttpApi} for the public class.
*/
import { ErrorResponse as OidcAuthError } from "oidc-client-ts";
import { checkObjectHasKeys, encodeParams } from "../utils.ts";
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { Method } from "./method.ts";
import { ConnectionError, MatrixError, TokenRefreshError } from "./errors.ts";
import { ConnectionError, MatrixError, TokenRefreshError, TokenRefreshLogoutError } from "./errors.ts";
import {
HttpApiEvent,
type HttpApiEventHandlerMap,
@@ -39,9 +37,9 @@ interface TypedResponse<T> extends Response {
json(): Promise<T>;
}
export type ResponseType<T, O extends IHttpOpts> = O extends undefined
? T
: O extends { onlyData: true }
export type ResponseType<T, O extends IHttpOpts> = O extends { json: false }
? string
: O extends { onlyData: true } | undefined
? T
: TypedResponse<T>;
@@ -234,7 +232,8 @@ export class FetchHttpApi<O extends IHttpOpts> {
return TokenRefreshOutcome.Success;
} catch (error) {
this.opts.logger?.warn("Failed to refresh token", error);
if (error instanceof OidcAuthError || error instanceof MatrixError) {
// If we get a TokenError or MatrixError, we should log out, otherwise assume transient
if (error instanceof TokenRefreshLogoutError || error instanceof MatrixError) {
return TokenRefreshOutcome.Logout;
}
return TokenRefreshOutcome.Failure;
@@ -371,7 +370,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
}
if (this.opts.onlyData) {
return json ? res.json() : res.text();
return (json ? res.json() : res.text()) as ResponseType<T, O>;
}
return res as ResponseType<T, O>;
}
@@ -1,3 +1,19 @@
/*
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.
*/
import { EventType } from "../@types/event.ts";
import { UpdateDelayedEventAction } from "../@types/requests.ts";
import type { MatrixClient } from "../client.ts";
@@ -11,44 +27,7 @@ import { type Focus } from "./focus.ts";
import { isLivekitFocusActive } from "./LivekitFocus.ts";
import { type MembershipConfig } from "./MatrixRTCSession.ts";
import { type EmptyObject } from "../@types/common.ts";
/**
* This interface defines what a MembershipManager uses and exposes.
* This interface is what we use to write tests and allows to change the actual implementation
* Without breaking tests because of some internal method renaming.
*
* @internal
*/
export interface IMembershipManager {
/**
* If we are trying to join the session.
* It does not reflect if the room state is already configures to represent us being joined.
* It only means that the Manager is running.
* @returns true if we intend to be participating in the MatrixRTC session
*/
isJoined(): boolean;
/**
* Start sending all necessary events to make this user participant in the RTC session.
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
* @param fociActive the active focus to use in the joined RTC membership event.
*/
join(fociPreferred: Focus[], fociActive?: Focus): void;
/**
* Send all necessary events to make this user leave the RTC session.
* @param timeout the maximum duration in ms until the promise is forced to resolve.
* @returns It resolves with true in case the leave was sent successfully.
* It resolves with false in case we hit the timeout before sending successfully.
*/
leave(timeout?: number): Promise<boolean>;
/**
* Call this if the MatrixRTC session members have changed.
*/
onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void>;
/**
* The used active focus in the currently joined session.
* @returns the used active focus in the currently joined session or undefined if not joined.
*/
getActiveFocus(): Focus | undefined;
}
import { type IMembershipManager, type MembershipManagerEvent, Status } from "./types.ts";
/**
* This internal class is used by the MatrixRTCSession to manage the local user's own membership of the session.
@@ -64,7 +43,8 @@ export interface IMembershipManager {
*
* It is recommended to only use this interface for testing to allow replacing this class.
*
* @internal
* @internal
* @deprecated Use {@link MembershipManager} instead
*/
export class LegacyMembershipManager implements IMembershipManager {
private relativeExpiry: number | undefined;
@@ -123,9 +103,35 @@ export class LegacyMembershipManager implements IMembershipManager {
private getOldestMembership: () => CallMembership | undefined,
) {}
public off(
event: MembershipManagerEvent.StatusChanged,
listener: (oldStatus: Status, newStatus: Status) => void,
): this {
logger.error("off is not implemented on LegacyMembershipManager");
return this;
}
public on(
event: MembershipManagerEvent.StatusChanged,
listener: (oldStatus: Status, newStatus: Status) => void,
): this {
logger.error("on is not implemented on LegacyMembershipManager");
return this;
}
public isJoined(): boolean {
return this.relativeExpiry !== undefined;
}
public isActivated(): boolean {
return this.isJoined();
}
/**
* Unimplemented
* @returns Status.Unknown
*/
public get status(): Status {
return Status.Unknown;
}
public join(fociPreferred: Focus[], fociActive?: Focus): void {
this.ownFocusActive = fociActive;
+53 -7
View File
@@ -25,8 +25,11 @@ import { RoomStateEvent } from "../models/room-state.ts";
import { type Focus } from "./focus.ts";
import { KnownMembership } from "../@types/membership.ts";
import { type MatrixEvent } from "../models/event.ts";
import { LegacyMembershipManager, type IMembershipManager } from "./MembershipManager.ts";
import { MembershipManager } from "./NewMembershipManager.ts";
import { EncryptionManager, type IEncryptionManager, type Statistics } from "./EncryptionManager.ts";
import { LegacyMembershipManager } from "./LegacyMembershipManager.ts";
import { logDurationSync } from "../utils.ts";
import type { IMembershipManager } from "./types.ts";
const logger = rootLogger.getChild("MatrixRTCSession");
@@ -39,6 +42,8 @@ export enum MatrixRTCSessionEvent {
JoinStateChanged = "join_state_changed",
// The key used to encrypt media has changed
EncryptionKeyChanged = "encryption_key_changed",
/** The membership manager had to shut down caused by an unrecoverable error */
MembershipManagerError = "membership_manager_error",
}
export type MatrixRTCSessionEventHandlerMap = {
@@ -52,9 +57,17 @@ export type MatrixRTCSessionEventHandlerMap = {
encryptionKeyIndex: number,
participantId: string,
) => void;
[MatrixRTCSessionEvent.MembershipManagerError]: (error: unknown) => void;
};
export interface MembershipConfig {
/**
* Use the new Manager.
*
* Default: `false`.
*/
useNewMembershipManager?: boolean;
/**
* The timeout (in milliseconds) after we joined the call, that our membership should expire
* unless we have explicitly updated it.
@@ -63,6 +76,17 @@ export interface MembershipConfig {
*/
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
* sends the updated state event early enough.
*
* A headroom of 1000ms and a `membershipExpiryTimeout` of 10000ms would result in the first membership event update after 9s and
* a membership event that would be considered expired after 10s.
*
* This value does not have an effect on the value of `SessionMembershipData.expires`.
*/
membershipExpiryTimeoutHeadroom?: number;
/**
* The period (in milliseconds) with which we check that our membership event still exists on the
* server. If it is not found we create it again.
@@ -90,6 +114,16 @@ export interface MembershipConfig {
* @deprecated It should be possible to make it stable without this.
*/
callMemberEventRetryJitter?: 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.
*/
maximumRateLimitRetryCount?: number;
/**
* The maximum number of retries that the manager will do for delayed event sending/updating and state event sending when a network error occurs.
*/
maximumNetworkErrorRetryCount?: number;
}
export interface EncryptionConfig {
@@ -318,18 +352,28 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
* @param joinConfig - Additional configuration for the joined session.
*/
public joinRoomSession(fociPreferred: Focus[], fociActive?: Focus, joinConfig?: JoinSessionConfig): void {
// create MembershipManager
if (this.isJoined()) {
logger.info(`Already joined to session in room ${this.roomSubset.roomId}: ignoring join call`);
return;
} else {
this.membershipManager = new LegacyMembershipManager(joinConfig, this.roomSubset, this.client, () =>
this.getOldestMembership(),
);
// Create MembershipManager
if (joinConfig?.useNewMembershipManager ?? false) {
this.membershipManager = new MembershipManager(joinConfig, this.roomSubset, this.client, () =>
this.getOldestMembership(),
);
} else {
this.membershipManager = new LegacyMembershipManager(joinConfig, this.roomSubset, this.client, () =>
this.getOldestMembership(),
);
}
}
// Join!
this.membershipManager!.join(fociPreferred, fociActive);
this.membershipManager!.join(fociPreferred, fociActive, (e) => {
logger.error("MembershipManager encountered an unrecoverable error: ", e);
this.emit(MatrixRTCSessionEvent.MembershipManagerError, e);
this.emit(MatrixRTCSessionEvent.JoinStateChanged, this.isJoined());
});
this.encryptionManager!.join(joinConfig);
this.emit(MatrixRTCSessionEvent.JoinStateChanged, true);
@@ -492,7 +536,9 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
if (changed) {
logger.info(`Memberships for call in room ${this.roomSubset.roomId} have changed: emitting`);
this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships);
logDurationSync(logger, "emit MatrixRTCSessionEvent.MembershipsChanged", () => {
this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships);
});
void this.membershipManager?.onRTCSessionMemberUpdate(this.memberships);
}
+898
View File
@@ -0,0 +1,898 @@
/*
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.
*/
import { EventType } from "../@types/event.ts";
import { UpdateDelayedEventAction } from "../@types/requests.ts";
import { type MatrixClient } from "../client.ts";
import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
import { logger as rootLogger } from "../logger.ts";
import { type Room } from "../models/room.ts";
import { defer, type IDeferred } from "../utils.ts";
import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts";
import { type Focus } from "./focus.ts";
import {
type IMembershipManager,
type MembershipManagerEventHandlerMap,
MembershipManagerEvent,
Status,
} from "./types.ts";
import { isLivekitFocusActive } from "./LivekitFocus.ts";
import { type MembershipConfig } from "./MatrixRTCSession.ts";
import { ActionScheduler, type ActionUpdate } from "./NewMembershipManagerActionScheduler.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
const logger = rootLogger.getChild("MatrixRTCSession");
/* MembershipActionTypes:
On Join: (1)
SendDelayedEvent (2)
(3)
(4)SendJoinEvent(4)
UpdateExpiry (s) (s)|RestartDelayedEvent
On Leave: STOP ALL ABOVE
SendScheduledDelayedLeaveEvent
(5)
SendLeaveEvent
(1) [Not found error] results in resending the delayed event
(2) [hasMemberEvent = true] Sending the delayed event if we
already have a call member event results jumping to the
RestartDelayedEvent loop directly
(3) [hasMemberEvent = false] if there is not call member event
sending it is the next step
(4) Both (UpdateExpiry and RestartDelayedEvent) actions are
scheduled when successfully sending the state event
(5) Only if delayed event sending failed (fallback)
(s) Successful restart/resend
*/
/**
* The different types of actions the MembershipManager can take.
* @internal
*/
export enum MembershipActionType {
SendDelayedEvent = "SendDelayedEvent",
// -> MembershipActionType.SendJoinEvent if successful
// -> DelayedLeaveActionType.SendDelayedEvent on error, retry sending the first delayed event.
// -> DelayedLeaveActionType.RestartDelayedEvent on success start updating the delayed event
SendJoinEvent = "SendJoinEvent",
// -> MembershipActionType.SendJoinEvent if we run into a rate limit and need to retry
// -> MembershipActionType.Update if we successfully send the join event then schedule the expire event update
// -> DelayedLeaveActionType.RestartDelayedEvent to recheck the delayed event
RestartDelayedEvent = "RestartDelayedEvent",
// -> DelayedLeaveActionType.SendMainDelayedEvent on missing delay id but there is a rtc state event
// -> DelayedLeaveActionType.SendDelayedEvent on missing delay id and there is no state event
// -> DelayedLeaveActionType.RestartDelayedEvent on success we schedule the next restart
UpdateExpiry = "UpdateExpiry",
// -> 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
// -> DelayedLeaveActionType.SendScheduledDelayedLeaveEvent on error we try again.
SendLeaveEvent = "SendLeaveEvent",
// -> MembershipActionType.SendLeaveEvent
}
/**
* @internal
*/
export interface MembershipManagerState {
/** The delayId we got when successfully sending the delayed leave event.
* Gets set to undefined if the server claims it cannot find the delayed event anymore. */
delayId?: string;
/** Stores how often we have update the `expires` field.
* `expireUpdateIterations` * `membershipEventExpiryTimeout` resolves to the value the expires field should contain next */
expireUpdateIterations: number;
/** The time at which we send the first state event. The time the call started from the DAG point of view.
* This is used to compute the local sleep timestamps when to next update the member event with a new expires value. */
startTime: number;
/** The manager is in the state where its actually connected to the session. */
hasMemberStateEvent: boolean;
// There can be multiple retries at once so we need to store counters per action
// e.g. the send update membership and the restart delayed could be rate limited at the same time.
/** Retry counter for rate limits */
rateLimitRetries: Map<MembershipActionType, number>;
/** Retry counter for other errors */
networkErrorRetries: Map<MembershipActionType, number>;
}
/**
* This class is responsible for sending all events relating to the own membership of a matrixRTC call.
* It has the following tasks:
* - Send the users leave delayed event before sending the membership
* - Send the users membership if the state machine is started
* - Check if the delayed event was canceled due to sending the membership
* - update the delayed event (`restart`)
* - Update the state event every ~5h = `DEFAULT_EXPIRE_DURATION` (so it does not get treated as expired)
* - When the state machine is stopped:
* - Disconnect the member
* - Stop the timer for the delay refresh
* - Stop the timer for updating the state event
*/
export class MembershipManager
extends TypedEventEmitter<MembershipManagerEvent, MembershipManagerEventHandlerMap>
implements IMembershipManager
{
private activated = false;
public isActivated(): boolean {
return this.activated;
}
// DEPRECATED use isActivated
public isJoined(): boolean {
return this.isActivated();
}
/**
* Puts the MembershipManager in a state where it tries to be joined.
* It will send delayed events and membership events
* @param fociPreferred
* @param focusActive
* @param onError This will be called once the membership manager encounters an unrecoverable error.
* This should bubble up the the frontend to communicate that the call does not work in the current environment.
*/
public join(fociPreferred: Focus[], focusActive?: Focus, onError?: (error: unknown) => void): void {
if (this.scheduler.running) {
logger.error("MembershipManager is already running. Ignoring join request.");
return;
}
this.fociPreferred = fociPreferred;
this.focusActive = focusActive;
this.leavePromiseDefer = undefined;
this.activated = true;
this.oldStatus = this.status;
this.state = MembershipManager.defaultState;
this.scheduler
.startWithJoin()
.catch((e) => {
logger.error("MembershipManager stopped because: ", e);
onError?.(e);
})
.finally(() => {
// Should already be set to false when calling `leave` in non error cases.
this.activated = false;
// Here the scheduler is not running anymore so we the `membershipLoopHandler` is not called to emit.
if (this.oldStatus && this.oldStatus !== this.status) {
this.emit(MembershipManagerEvent.StatusChanged, this.oldStatus, this.status);
}
if (!this.scheduler.running) {
this.leavePromiseDefer?.resolve(true);
this.leavePromiseDefer = undefined;
}
});
}
/**
* Leave from the call (Send an rtc session event with content: `{}`)
* @param timeout the maximum duration this promise will take to resolve
* @returns true if it managed to leave and false if the timeout condition happened.
*/
public leave(timeout?: number): Promise<boolean> {
if (!this.scheduler.running) {
logger.warn("Called MembershipManager.leave() even though the MembershipManager is not running");
return Promise.resolve(true);
}
// We use the promise to track if we already scheduled a leave event
// So we do not check scheduler.actions/scheduler.insertions
if (!this.leavePromiseDefer) {
// reset scheduled actions so we will not do any new actions.
this.leavePromiseDefer = defer<boolean>();
this.activated = false;
this.scheduler.initiateLeave();
if (timeout) setTimeout(() => this.leavePromiseDefer?.resolve(false), timeout);
}
return this.leavePromiseDefer.promise;
}
private leavePromiseDefer?: IDeferred<boolean>;
public async onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void> {
const isMyMembership = (m: CallMembership): boolean =>
m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId();
if (this.isJoined() && !memberships.some(isMyMembership)) {
// If one of these actions are scheduled or are getting inserted in the next iteration, we should already
// take care of our missing membership.
const sendingMembershipActions = [
MembershipActionType.SendDelayedEvent,
MembershipActionType.SendJoinEvent,
];
logger.warn("Missing own membership: force re-join");
if (this.scheduler.actions.find((a) => sendingMembershipActions.includes(a.type as MembershipActionType))) {
logger.error(
"NewMembershipManger tried adding another `SendFirstDelayedEvent` actions even though we already have one in the Queue\nActionQueueOnMemberUpdate:",
this.scheduler.actions,
);
} else {
// Only react to our own membership missing if we have not already scheduled sending a new membership DirectMembershipManagerAction.Join
this.state.hasMemberStateEvent = false;
this.scheduler.initiateJoin();
}
}
return Promise.resolve();
}
public getActiveFocus(): Focus | undefined {
if (this.focusActive) {
// A livekit active focus
if (isLivekitFocusActive(this.focusActive)) {
if (this.focusActive.focus_selection === "oldest_membership") {
const oldestMembership = this.getOldestMembership();
return oldestMembership?.getPreferredFoci()[0];
}
} else {
logger.warn("Unknown own ActiveFocus type. This makes it impossible to connect to an SFU.");
}
} else {
// We do not understand the membership format (could be legacy). We default to oldestMembership
// Once there are other methods this is a hard error!
const oldestMembership = this.getOldestMembership();
return oldestMembership?.getPreferredFoci()[0];
}
}
/**
* @throws if the client does not return user or device id.
* @param joinConfig
* @param room
* @param client
* @param getOldestMembership
*/
public constructor(
private joinConfig: MembershipConfig | undefined,
private room: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion">,
private client: Pick<
MatrixClient,
| "getUserId"
| "getDeviceId"
| "sendStateEvent"
| "_unstable_sendDelayedStateEvent"
| "_unstable_updateDelayedEvent"
>,
private getOldestMembership: () => CallMembership | undefined,
) {
super();
const [userId, deviceId] = [this.client.getUserId(), this.client.getDeviceId()];
if (userId === null) throw Error("Missing userId in client");
if (deviceId === null) throw Error("Missing deviceId in client");
this.deviceId = deviceId;
this.stateKey = this.makeMembershipStateKey(userId, deviceId);
this.state = MembershipManager.defaultState;
}
// MembershipManager mutable state.
private state: MembershipManagerState;
private static get defaultState(): MembershipManagerState {
return {
hasMemberStateEvent: false,
delayId: undefined,
startTime: 0,
rateLimitRetries: new Map(),
networkErrorRetries: new Map(),
expireUpdateIterations: 1,
};
}
// Membership Event static parameters:
private deviceId: string;
private stateKey: string;
private fociPreferred?: Focus[];
private focusActive?: Focus;
// Config:
private membershipServerSideExpiryTimeoutOverride?: number;
private get callMemberEventRetryDelayMinimum(): number {
return this.joinConfig?.callMemberEventRetryDelayMinimum ?? 3_000;
}
private get membershipEventExpiryTimeout(): number {
return this.joinConfig?.membershipExpiryTimeout ?? DEFAULT_EXPIRE_DURATION;
}
private get membershipEventExpiryTimeoutHeadroom(): number {
return this.joinConfig?.membershipExpiryTimeoutHeadroom ?? 5_000;
}
private computeNextExpiryActionTs(iteration: number): number {
return (
this.state.startTime +
this.membershipEventExpiryTimeout * iteration -
this.membershipEventExpiryTimeoutHeadroom
);
}
private get membershipServerSideExpiryTimeout(): number {
return (
this.membershipServerSideExpiryTimeoutOverride ??
this.joinConfig?.membershipServerSideExpiryTimeout ??
8_000
);
}
private get membershipKeepAlivePeriod(): number {
return this.joinConfig?.membershipKeepAlivePeriod ?? 5_000;
}
private get maximumRateLimitRetryCount(): number {
return this.joinConfig?.maximumRateLimitRetryCount ?? 10;
}
private get maximumNetworkErrorRetryCount(): number {
return this.joinConfig?.maximumNetworkErrorRetryCount ?? 10;
}
// Scheduler:
private oldStatus?: Status;
private scheduler = new ActionScheduler((type): Promise<ActionUpdate> => {
if (this.oldStatus) {
// we put this at the beginning of the actions scheduler loop handle callback since it is a loop this
// is equivalent to running it at the end of the loop. (just after applying the status/action list changes)
// This order is required because this method needs to return the action updates.
logger.debug(`MembershipManager applied action changes. Status: ${this.oldStatus} -> ${this.status}`);
if (this.oldStatus !== this.status) {
this.emit(MembershipManagerEvent.StatusChanged, this.oldStatus, this.status);
}
}
this.oldStatus = this.status;
logger.debug(`MembershipManager before processing action. status=${this.oldStatus}`);
return this.membershipLoopHandler(type);
});
// LOOP HANDLER:
private async membershipLoopHandler(type: MembershipActionType): Promise<ActionUpdate> {
switch (type) {
case MembershipActionType.SendDelayedEvent: {
// Before we start we check if we come from a state where we have a delay id.
if (!this.state.delayId) {
return this.sendOrResendDelayedLeaveEvent(); // Normal case without any previous delayed id.
} else {
// This can happen if someone else (or another client) removes our own membership event.
// It will trigger `onRTCSessionMemberUpdate` queue `MembershipActionType.SendFirstDelayedEvent`.
// We might still have our delayed event from the previous participation and dependent on the server this might not
// get removed automatically if the state changes. Hence, it would remove our membership unexpectedly shortly after the rejoin.
//
// In this block we will try to cancel this delayed event before setting up a new one.
return this.cancelKnownDelayIdBeforeSendFirstDelayedEvent(this.state.delayId);
}
}
case MembershipActionType.RestartDelayedEvent: {
if (!this.state.delayId) {
// Delay id got reset. This action was used to check if the hs canceled the delayed event when the join state got sent.
return createInsertActionUpdate(MembershipActionType.SendDelayedEvent);
}
return this.restartDelayedEvent(this.state.delayId);
}
case MembershipActionType.SendScheduledDelayedLeaveEvent: {
// We are already good
if (!this.state.hasMemberStateEvent) {
return { replace: [] };
}
if (this.state.delayId) {
return this.sendScheduledDelayedLeaveEventOrFallbackToSendLeaveEvent(this.state.delayId);
} else {
return createInsertActionUpdate(MembershipActionType.SendLeaveEvent);
}
}
case MembershipActionType.SendJoinEvent: {
return this.sendJoinEvent();
}
case MembershipActionType.UpdateExpiry: {
return this.updateExpiryOnJoinedEvent();
}
case MembershipActionType.SendLeaveEvent: {
// We are good already
if (!this.state.hasMemberStateEvent) {
return { replace: [] };
}
// This is only a fallback in case we do not have working delayed events support.
// first we should try to just send the scheduled leave event
return this.sendFallbackLeaveEvent();
}
}
}
// HANDLERS (used in the membershipLoopHandler)
private async sendOrResendDelayedLeaveEvent(): Promise<ActionUpdate> {
// We can reach this at the start of a call (where we do not yet have a membership: state.hasMemberStateEvent=false)
// or during a call if the state event canceled our delayed event or caused by an unexpected error that removed our delayed event.
// (Another client could have canceled it, the homeserver might have removed/lost it due to a restart, ...)
// In the `then` and `catch` block we treat both cases differently. "if (this.state.hasMemberStateEvent) {} else {}"
return await this.client
._unstable_sendDelayedStateEvent(
this.room.roomId,
{
delay: this.membershipServerSideExpiryTimeout,
},
EventType.GroupCallMemberPrefix,
{}, // leave event
this.stateKey,
)
.then((response) => {
// On success we reset retries and set delayId.
this.resetRateLimitCounter(MembershipActionType.SendDelayedEvent);
this.state.delayId = response.delay_id;
if (this.state.hasMemberStateEvent) {
// This action was scheduled because the previous delayed event was cancelled
// due to lack of https://github.com/element-hq/synapse/pull/17810
return createInsertActionUpdate(
MembershipActionType.RestartDelayedEvent,
this.membershipKeepAlivePeriod,
);
} else {
// This action was scheduled because we are in the process of joining
return createInsertActionUpdate(MembershipActionType.SendJoinEvent);
}
})
.catch((e) => {
const repeatActionType = MembershipActionType.SendDelayedEvent;
if (this.manageMaxDelayExceededSituation(e)) {
return createInsertActionUpdate(repeatActionType);
}
const update = this.actionUpdateFromErrors(e, repeatActionType, "sendDelayedStateEvent");
if (update) return update;
if (this.state.hasMemberStateEvent) {
// This action was scheduled because the previous delayed event was cancelled
// due to lack of https://github.com/element-hq/synapse/pull/17810
// Don't do any other delayed event work if its not supported.
if (this.isUnsupportedDelayedEndpoint(e)) return {};
throw Error("Could not send delayed event, even though delayed events are supported. " + e);
} else {
// This action was scheduled because we are in the process of joining
// log and fall through
if (this.isUnsupportedDelayedEndpoint(e)) {
logger.info("Not using delayed event because the endpoint is not supported");
} else {
logger.info("Not using delayed event because: " + e);
}
// On any other error we fall back to not using delayed events and send the join state event immediately
return createInsertActionUpdate(MembershipActionType.SendJoinEvent);
}
});
}
private async cancelKnownDelayIdBeforeSendFirstDelayedEvent(delayId: string): Promise<ActionUpdate> {
// Remove all running updates and restarts
return await this.client
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Cancel)
.then(() => {
this.state.delayId = undefined;
this.resetRateLimitCounter(MembershipActionType.SendDelayedEvent);
return createReplaceActionUpdate(MembershipActionType.SendDelayedEvent);
})
.catch((e) => {
const repeatActionType = MembershipActionType.SendDelayedEvent;
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
if (update) return update;
if (this.isNotFoundError(e)) {
// If we get a M_NOT_FOUND we know that the delayed event got already removed.
// This means we are good and can set it to undefined and run this again.
this.state.delayId = undefined;
return createReplaceActionUpdate(repeatActionType);
}
if (this.isUnsupportedDelayedEndpoint(e)) {
return createReplaceActionUpdate(MembershipActionType.SendJoinEvent);
}
// We do not just ignore and log this error since we would also need to reset the delayId.
// This becomes an unrecoverable error case since something is significantly off if we don't hit any of the above cases
// when state.delayId !== undefined
// We do not just ignore and log this error since we would also need to reset the delayId.
// It is cleaner if we, the frontend, rejoins instead of resetting the delayId here and behaving like in the success case.
throw Error(
"We failed to cancel a delayed event where we already had a delay id with an error we cannot automatically handle",
);
});
}
private async restartDelayedEvent(delayId: string): Promise<ActionUpdate> {
return await this.client
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Restart)
.then(() => {
this.resetRateLimitCounter(MembershipActionType.RestartDelayedEvent);
return createInsertActionUpdate(
MembershipActionType.RestartDelayedEvent,
this.membershipKeepAlivePeriod,
);
})
.catch((e) => {
const repeatActionType = MembershipActionType.RestartDelayedEvent;
if (this.isNotFoundError(e)) {
this.state.delayId = undefined;
return createInsertActionUpdate(MembershipActionType.SendDelayedEvent);
}
// If the HS does not support delayed events we wont reschedule.
if (this.isUnsupportedDelayedEndpoint(e)) return {};
// TODO this also needs a test: get rate limit while checking id delayed event is scheduled
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
if (update) return update;
// In other error cases we have no idea what is happening
throw Error("Could not restart delayed event, even though delayed events are supported. " + e);
});
}
private async sendScheduledDelayedLeaveEventOrFallbackToSendLeaveEvent(delayId: string): Promise<ActionUpdate> {
return await this.client
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Send)
.then(() => {
this.state.hasMemberStateEvent = false;
this.resetRateLimitCounter(MembershipActionType.SendScheduledDelayedLeaveEvent);
return { replace: [] };
})
.catch((e) => {
const repeatActionType = MembershipActionType.SendLeaveEvent;
if (this.isUnsupportedDelayedEndpoint(e)) return {};
if (this.isNotFoundError(e)) {
this.state.delayId = undefined;
return createInsertActionUpdate(repeatActionType);
}
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
if (update) return update;
// On any other error we fall back to SendLeaveEvent (this includes hard errors from rate limiting)
logger.warn(
"Encountered unexpected error during SendScheduledDelayedLeaveEvent. Falling back to SendLeaveEvent",
e,
);
return createInsertActionUpdate(repeatActionType);
});
}
private async sendJoinEvent(): Promise<ActionUpdate> {
return await this.client
.sendStateEvent(
this.room.roomId,
EventType.GroupCallMemberPrefix,
this.makeMyMembership(this.membershipEventExpiryTimeout),
this.stateKey,
)
.then(() => {
this.state.startTime = Date.now();
// The next update should already use twice the membershipEventExpiryTimeout
this.state.expireUpdateIterations = 1;
this.state.hasMemberStateEvent = true;
this.resetRateLimitCounter(MembershipActionType.SendJoinEvent);
return {
insert: [
{ ts: Date.now(), type: MembershipActionType.RestartDelayedEvent },
{
ts: this.computeNextExpiryActionTs(this.state.expireUpdateIterations),
type: MembershipActionType.UpdateExpiry,
},
],
};
})
.catch((e) => {
const update = this.actionUpdateFromErrors(e, MembershipActionType.SendJoinEvent, "sendStateEvent");
if (update) return update;
throw e;
});
}
private async updateExpiryOnJoinedEvent(): Promise<ActionUpdate> {
const nextExpireUpdateIteration = this.state.expireUpdateIterations + 1;
return await this.client
.sendStateEvent(
this.room.roomId,
EventType.GroupCallMemberPrefix,
this.makeMyMembership(this.membershipEventExpiryTimeout * nextExpireUpdateIteration),
this.stateKey,
)
.then(() => {
// Success, we reset retries and schedule update.
this.resetRateLimitCounter(MembershipActionType.UpdateExpiry);
this.state.expireUpdateIterations = nextExpireUpdateIteration;
return {
insert: [
{
ts: this.computeNextExpiryActionTs(nextExpireUpdateIteration),
type: MembershipActionType.UpdateExpiry,
},
],
};
})
.catch((e) => {
const update = this.actionUpdateFromErrors(e, MembershipActionType.UpdateExpiry, "sendStateEvent");
if (update) return update;
throw e;
});
}
private async sendFallbackLeaveEvent(): Promise<ActionUpdate> {
return await this.client
.sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, {}, this.stateKey)
.then(() => {
this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent);
this.state.hasMemberStateEvent = false;
return { replace: [] };
})
.catch((e) => {
const update = this.actionUpdateFromErrors(e, MembershipActionType.SendLeaveEvent, "sendStateEvent");
if (update) return update;
throw e;
});
}
// HELPERS
private makeMembershipStateKey(localUserId: string, localDeviceId: string): string {
const stateKey = `${localUserId}_${localDeviceId}`;
if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) {
return stateKey;
} else {
return `_${stateKey}`;
}
}
/**
* Constructs our own membership
*/
private makeMyMembership(expires: number): SessionMembershipData {
return {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: this.deviceId,
expires,
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
foci_preferred: this.fociPreferred ?? [],
};
}
// Error checks and handlers
/**
* Check if its a NOT_FOUND error
* @param error the error causing this handler check/execution
* @returns true if its a not found error
*/
private isNotFoundError(error: unknown): boolean {
return error instanceof MatrixError && error.errcode === "M_NOT_FOUND";
}
/**
* Check if this is a DelayExceeded timeout and update the TimeoutOverride for the next try
* @param error the error causing this handler check/execution
* @returns true if its a delay exceeded error and we updated the local TimeoutOverride
*/
private manageMaxDelayExceededSituation(error: unknown): boolean {
if (
error instanceof MatrixError &&
error.errcode === "M_UNKNOWN" &&
error.data["org.matrix.msc4140.errcode"] === "M_MAX_DELAY_EXCEEDED"
) {
const maxDelayAllowed = error.data["org.matrix.msc4140.max_delay"];
if (typeof maxDelayAllowed === "number" && this.membershipServerSideExpiryTimeout > maxDelayAllowed) {
this.membershipServerSideExpiryTimeoutOverride = maxDelayAllowed;
}
logger.warn("Retry sending delayed disconnection event due to server timeout limitations:", error);
return true;
}
return false;
}
private actionUpdateFromErrors(
error: unknown,
type: MembershipActionType,
method: string,
): ActionUpdate | undefined {
const updateLimit = this.actionUpdateFromRateLimitError(error, method, type);
if (updateLimit) return updateLimit;
const updateNetwork = this.actionUpdateFromNetworkErrorRetry(error, type);
if (updateNetwork) return updateNetwork;
}
/**
* Check if we have a rate limit error and schedule the same action again if we dont exceed the rate limit retry count yet.
* @param error the error causing this handler check/execution
* @param method the method used for the throw message
* @param type which MembershipActionType we reschedule because of a rate limit.
* @throws If it is a rate limit error and the retry count got exceeded
* @returns Returns true if we handled the error by rescheduling the correct next action.
* Returns false if it is not a network error.
*/
private actionUpdateFromRateLimitError(
error: unknown,
method: string,
type: MembershipActionType,
): ActionUpdate | undefined {
// "Is rate limit"-boundary
if (!((error instanceof HTTPError || error instanceof MatrixError) && error.isRateLimitError())) {
return undefined;
}
// retry boundary
const rateLimitRetries = this.state.rateLimitRetries.get(type) ?? 0;
if (rateLimitRetries < this.maximumRateLimitRetryCount) {
let resendDelay: number;
const defaultMs = 5000;
try {
resendDelay = error.getRetryAfterMs() ?? defaultMs;
logger.info(`Rate limited by server, retrying in ${resendDelay}ms`);
} catch (e) {
logger.warn(
`Error while retrieving a rate-limit retry delay, retrying after default delay of ${defaultMs}`,
e,
);
resendDelay = defaultMs;
}
this.state.rateLimitRetries.set(type, rateLimitRetries + 1);
return createInsertActionUpdate(type, resendDelay);
}
throw Error("Exceeded maximum retries for " + type + " attempts (client." + method + "): " + (error as Error));
}
/**
* FIXME Don't Check the error and retry the same MembershipAction again in the configured time and for the configured retry count.
* @param error the error causing this handler check/execution
* @param type the action type that we need to repeat because of the error
* @throws If it is a network error and the retry count got exceeded
* @returns
* Returns true if we handled the error by rescheduling the correct next action.
* Returns false if it is not a network error.
*/
private actionUpdateFromNetworkErrorRetry(error: unknown, type: MembershipActionType): ActionUpdate | undefined {
// "Is a network error"-boundary
const retries = this.state.networkErrorRetries.get(type) ?? 0;
const retryDurationString = this.callMemberEventRetryDelayMinimum / 1000 + "s";
const retryCounterString = "(" + retries + "/" + this.maximumNetworkErrorRetryCount + ")";
if (error instanceof Error && error.name === "AbortError") {
logger.warn(
"Network local timeout error while sending event, retrying in " +
retryDurationString +
" " +
retryCounterString,
error,
);
} else if (error instanceof Error && error.message.includes("updating delayed event")) {
// TODO: We do not want error message matching here but instead the error should be a typed HTTPError
// and be handled below automatically (the same as in the SPA case).
//
// The error originates because of https://github.com/matrix-org/matrix-widget-api/blob/5d81d4a26ff69e4bd3ddc79a884c9527999fb2f4/src/ClientWidgetApi.ts#L698-L701
// uses `e` instance of HttpError (and not MatrixError)
// The element web widget driver (only checks for MatrixError) is then failing to process (`processError`) it as a typed error: https://github.com/element-hq/element-web/blob/471712cbf06a067e5499bd5d2d7a75f693d9a12d/src/stores/widgets/StopGapWidgetDriver.ts#L711-L715
// So it will not call: `error.asWidgetApiErrorData()` which is also missing for `HttpError`
//
// A proper fix would be to either find a place to convert the `HttpError` into a `MatrixError` and the `processError`
// method to handle it as expected or to adjust `processError` to also process `HttpError`'s.
logger.warn(
"delayed event update timeout error, retrying in " + retryDurationString + " " + retryCounterString,
error,
);
} else if (error instanceof ConnectionError) {
logger.warn(
"Network connection error while sending event, retrying in " +
retryDurationString +
" " +
retryCounterString,
error,
);
} else if (
(error instanceof HTTPError || error instanceof MatrixError) &&
typeof error.httpStatus === "number" &&
error.httpStatus >= 500 &&
error.httpStatus < 600
) {
logger.warn(
"Server error while sending event, retrying in " + retryDurationString + " " + retryCounterString,
error,
);
} else {
return undefined;
}
// retry boundary
if (retries < this.maximumNetworkErrorRetryCount) {
this.state.networkErrorRetries.set(type, retries + 1);
return createInsertActionUpdate(type, this.callMemberEventRetryDelayMinimum);
}
// Failure
throw Error(
"Reached maximum (" + this.maximumNetworkErrorRetryCount + ") retries cause by: " + (error as Error),
);
}
/**
* Check if its an UnsupportedDelayedEventsEndpointError and which implies that we cannot do any delayed event logic
* @param error The error to check
* @returns true it its an UnsupportedDelayedEventsEndpointError
*/
private isUnsupportedDelayedEndpoint(error: unknown): boolean {
return error instanceof UnsupportedDelayedEventsEndpointError;
}
private resetRateLimitCounter(type: MembershipActionType): void {
this.state.rateLimitRetries.set(type, 0);
this.state.networkErrorRetries.set(type, 0);
}
public get status(): Status {
const actions = this.scheduler.actions;
if (actions.length === 1) {
const { type } = actions[0];
switch (type) {
case MembershipActionType.SendDelayedEvent:
case MembershipActionType.SendJoinEvent:
return Status.Connecting;
case MembershipActionType.UpdateExpiry: // where no delayed events
return Status.Connected;
case MembershipActionType.SendScheduledDelayedLeaveEvent:
case MembershipActionType.SendLeaveEvent:
return Status.Disconnecting;
default:
// pass through as not expected
}
} else if (actions.length === 2) {
const types = actions.map((a) => a.type);
// normal state for connected with delayed events
if (
(types.includes(MembershipActionType.RestartDelayedEvent) ||
(types.includes(MembershipActionType.SendDelayedEvent) && this.state.hasMemberStateEvent)) &&
types.includes(MembershipActionType.UpdateExpiry)
) {
return Status.Connected;
}
} else if (actions.length === 3) {
const types = actions.map((a) => a.type);
// It is a correct connected state if we already schedule the next Restart but have not yet cleaned up
// the current restart.
if (
types.filter((t) => t === MembershipActionType.RestartDelayedEvent).length === 2 &&
types.includes(MembershipActionType.UpdateExpiry)
) {
return Status.Connected;
}
}
if (!this.scheduler.running) {
return Status.Disconnected;
}
logger.error("MembershipManager has an unknown state. Actions: ", actions);
return Status.Unknown;
}
}
function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
return {
insert: [{ ts: Date.now() + (offset ?? 0), type }],
};
}
function createReplaceActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
return {
replace: [{ ts: Date.now() + (offset ?? 0), type }],
};
}
@@ -0,0 +1,133 @@
import { logger as rootLogger } from "../logger.ts";
import { type EmptyObject } from "../matrix.ts";
import { sleep } from "../utils.ts";
import { MembershipActionType } from "./NewMembershipManager.ts";
const logger = rootLogger.getChild("MatrixRTCSession");
/** @internal */
export interface Action {
/**
* When this action should be executed
*/
ts: number;
/**
* The state of the different loops
* can also be thought of as the type of the action
*/
type: MembershipActionType;
}
/** @internal */
export type ActionUpdate =
| {
/** Replace all existing scheduled actions with this new array */
replace: Action[];
}
| {
/** Add these actions to the existing scheduled actions */
insert: Action[];
}
| EmptyObject;
/**
* This scheduler tracks the state of the current membership participation
* and runs one central timer that wakes up a handler callback with the correct action + state
* whenever necessary.
*
* It can also be awakened whenever a new action is added which is
* earlier then the current "next awake".
* @internal
*/
export class ActionScheduler {
/**
* This is tracking the state of the scheduler loop.
* Only used to prevent starting the loop twice.
*/
public running = false;
public constructor(
/** This is the callback called for each scheduled action (`this.addAction()`) */
private membershipLoopHandler: (type: MembershipActionType) => Promise<ActionUpdate>,
) {}
// function for the wakeup mechanism (in case we add an action externally and need to leave the current sleep)
private wakeup: (update: ActionUpdate) => void = (update: ActionUpdate): void => {
logger.error("Cannot call wakeup before calling `startWithJoin()`");
};
private _actions: Action[] = [];
public get actions(): Action[] {
return this._actions;
}
/**
* This starts the main loop of the membership manager that handles event sending, delayed event sending and delayed event restarting.
* @param initialActions The initial actions the manager will start with. It should be enough to pass: DelayedLeaveActionType.Initial
* @returns Promise that resolves once all actions have run and no more are scheduled.
* @throws This throws an error if one of the actions throws.
* In most other error cases the manager will try to handle any server errors by itself.
*/
public async startWithJoin(): Promise<void> {
if (this.running) {
logger.error("Cannot call startWithJoin() on NewMembershipActionScheduler while already running");
return;
}
this.running = true;
this._actions = [{ ts: Date.now(), type: MembershipActionType.SendDelayedEvent }];
try {
while (this._actions.length > 0) {
// Sort so next (smallest ts) action is at the beginning
this._actions.sort((a, b) => a.ts - b.ts);
const nextAction = this._actions[0];
let wakeupUpdate: ActionUpdate | undefined = undefined;
// while we await for the next action, wakeup has to resolve the wakeupPromise
const wakeupPromise = new Promise<void>((resolve) => {
this.wakeup = (update: ActionUpdate): void => {
wakeupUpdate = update;
resolve();
};
});
if (nextAction.ts > Date.now()) await Promise.race([wakeupPromise, sleep(nextAction.ts - Date.now())]);
let handlerResult: ActionUpdate = {};
if (!wakeupUpdate) {
logger.debug(
`Current MembershipManager processing: ${nextAction.type}\nQueue:`,
this._actions,
`\nDate.now: "${Date.now()}`,
);
try {
// `this.wakeup` can also be called and sets the `wakeupUpdate` object while we are in the handler.
handlerResult = await this.membershipLoopHandler(nextAction.type as MembershipActionType);
} catch (e) {
throw Error(`The MembershipManager shut down because of the end condition: ${e}`);
}
}
// remove the processed action only after we are done processing
this._actions.splice(0, 1);
// The wakeupUpdate always wins since that is a direct external update.
const actionUpdate = wakeupUpdate ?? handlerResult;
if ("replace" in actionUpdate) {
this._actions = actionUpdate.replace;
} else if ("insert" in actionUpdate) {
this._actions.push(...actionUpdate.insert);
}
}
} finally {
// Set the rtc session running state since we cannot recover from here and the consumer user of the
// MatrixRTCSession class needs to manually rejoin.
this.running = false;
}
logger.debug("Leave MembershipManager ActionScheduler loop (no more actions)");
}
public initiateJoin(): void {
this.wakeup?.({ replace: [{ ts: Date.now(), type: MembershipActionType.SendDelayedEvent }] });
}
public initiateLeave(): void {
this.wakeup?.({ replace: [{ ts: Date.now(), type: MembershipActionType.SendScheduledDelayedLeaveEvent }] });
}
}
+1
View File
@@ -20,3 +20,4 @@ export * from "./LivekitFocus.ts";
export * from "./MatrixRTCSession.ts";
export * from "./MatrixRTCSessionManager.ts";
export type * from "./types.ts";
export { Status, MembershipManagerEvent } from "./types.ts";
+84 -1
View File
@@ -13,7 +13,10 @@ 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.
*/
import { type IMentions } from "../matrix.ts";
import type { IMentions } from "../matrix.ts";
import type { CallMembership } from "./CallMembership.ts";
import type { Focus } from "./focus.ts";
export interface EncryptionKeyEntry {
index: number;
key: string;
@@ -34,3 +37,83 @@ export interface ICallNotifyContent {
"notify_type": CallNotifyType;
"call_id": string;
}
export enum Status {
Disconnected = "Disconnected",
Connecting = "Connecting",
ConnectingFailed = "ConnectingFailed",
Connected = "Connected",
Reconnecting = "Reconnecting",
Disconnecting = "Disconnecting",
Stuck = "Stuck",
Unknown = "Unknown",
}
export enum MembershipManagerEvent {
StatusChanged = "StatusChanged",
}
export type MembershipManagerEventHandlerMap = {
[MembershipManagerEvent.StatusChanged]: (prefStatus: Status, newStatus: Status) => void;
};
/**
* This interface defines what a MembershipManager uses and exposes.
* This interface is what we use to write tests and allows changing the actual implementation
* without breaking tests because of some internal method renaming.
*
* @internal
*/
export interface IMembershipManager {
/**
* 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.
* It only means that the Manager should be trying to connect or to disconnect running.
* The Manager is still running right after isJoined becomes false to send the disconnect events.
* @returns true if we intend to be participating in the MatrixRTC session
* @deprecated This name is confusing and replaced by `isActivated()`. (Returns the same as `isActivated()`)
*/
isJoined(): boolean;
/**
* If the manager is activated. This means it tries to do its job to join the call, resend state events...
* It does not imply that the room state is already configured to represent being joined.
* It means that the Manager tries to connect or is connected. ("the manager is still active")
* Once `leave()` is called the manager is not activated anymore but still running until `leave()` resolves.
* @returns `true` if we intend to be participating in the MatrixRTC session
*/
isActivated(): boolean;
/**
* Get the actual connection status of the manager.
*/
get status(): Status;
/**
* The current status while the manager is activated
*/
/**
* 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.
* @param fociActive the active focus to use in the joined RTC membership event.
* @throws can throw if it exceeds a configured maximum retry.
*/
join(fociPreferred: Focus[], fociActive?: Focus, onError?: (error: unknown) => void): void;
/**
* Send all necessary events to make this user leave the RTC session.
* @param timeout the maximum duration in ms until the promise is forced to resolve.
* @returns It resolves with true in case the leave was sent successfully.
* It resolves with false in case we hit the timeout before sending successfully.
*/
leave(timeout?: number): Promise<boolean>;
/**
* Call this if the MatrixRTC session members have changed.
*/
onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void>;
/**
* The used active focus in the currently joined session.
* @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;
}
+31
View File
@@ -14,9 +14,40 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* A stripped m.room.member event which contains the key renderable fields from the event,
* sent only in simplified sliding sync (not `/v3/sync`).
* This is very similar to MSC4186Hero from sliding-sync.ts but an internal format with
* camelCase rather than underscores.
*/
export type Hero = {
userId: string;
displayName?: string;
avatarUrl?: string;
/**
* If true, the hero is from an MSC4186 summary, in which case `displayName` and `avatarUrl` will
* have been set by the server if available. If false, the `Hero` has been constructed from a `/v3/sync` response,
* so these fields will always be undefined.
*/
fromMSC4186: boolean;
};
/**
* High level summary information for a room, as returned by `/v3/sync`.
*/
export interface IRoomSummary {
/**
* The room heroes: a selected set of members that can be used when summarising or
* generating a name for a room. List of user IDs.
*/
"m.heroes": string[];
/**
* The number of joined members in the room.
*/
"m.joined_member_count"?: number;
/**
* The number of invited members in the room.
*/
"m.invited_member_count"?: number;
}
+133 -23
View File
@@ -35,7 +35,7 @@ import {
} from "./event.ts";
import { EventStatus } from "./event-status.ts";
import { RoomMember } from "./room-member.ts";
import { type IRoomSummary, RoomSummary } from "./room-summary.ts";
import { type IRoomSummary, type Hero, RoomSummary } from "./room-summary.ts";
import { logger } from "../logger.ts";
import { TypedReEmitter } from "../ReEmitter.ts";
import {
@@ -77,6 +77,7 @@ import { compareEventOrdering } from "./compare-event-ordering.ts";
import * as utils from "../utils.ts";
import { KnownMembership, type Membership } from "../@types/membership.ts";
import { type Capabilities, type IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities.ts";
import { type MSC4186Hero } from "../sliding-sync.ts";
// These constants are used as sane defaults when the homeserver doesn't support
// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
@@ -335,6 +336,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
public readonly reEmitter: TypedReEmitter<RoomEmittedEvents, RoomEventHandlerMap>;
private txnToEvent: Map<string, MatrixEvent> = new Map(); // Pending in-flight requests { string: MatrixEvent }
private notificationCounts: NotificationCount = {};
private bumpStamp: number | undefined = undefined;
private readonly threadNotifications = new Map<string, NotificationCount>();
public readonly cachedThreadReadReceipts = new Map<string, CachedReceiptStructure[]>();
// Useful to know at what point the current user has started using threads in this room
@@ -361,7 +363,16 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// read by megolm via getter; boolean value - null indicates "use global value"
private blacklistUnverifiedDevices?: boolean;
private selfMembership?: Membership;
private summaryHeroes: string[] | null = null;
/**
* A `Hero` is a stripped `m.room.member` event which contains the important renderable fields from the event.
*
* It is used in MSC4186 (Simplified Sliding Sync) as a replacement for the old `summary` field.
*
* When we are doing old-style (`/v3/sync`) sync, we simulate the SSS behaviour by constructing
* a `Hero` object based on the user id we get from the summary. Obviously, in that case,
* the `Hero` will lack a `displayName` or `avatarUrl`.
*/
private heroes: Hero[] | null = null;
// flags to stop logspam about missing m.room.create events
private getTypeWarning = false;
private getVersionWarning = false;
@@ -879,7 +890,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// fall back to summary information
const memberCount = this.getInvitedAndJoinedMemberCount();
if (memberCount === 2) {
return this.summaryHeroes?.[0];
return this.heroes?.[0]?.userId;
}
}
}
@@ -897,8 +908,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
}
// Remember, we're assuming this room is a DM, so returning the first member we find should be fine
if (Array.isArray(this.summaryHeroes) && this.summaryHeroes.length) {
return this.summaryHeroes[0];
if (Array.isArray(this.heroes) && this.heroes.length) {
return this.heroes[0].userId;
}
const members = this.currentState.getMembers();
const anyMember = members.find((m) => m.userId !== this.myUserId);
@@ -940,12 +951,45 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
if (nonFunctionalMemberCount > 2) return;
// Prefer the list of heroes, if present. It should only include the single other user in the DM.
const nonFunctionalHeroes = this.summaryHeroes?.filter((h) => !functionalMembers.includes(h));
const nonFunctionalHeroes = this.heroes?.filter((h) => !functionalMembers.includes(h.userId));
const hasHeroes = Array.isArray(nonFunctionalHeroes) && nonFunctionalHeroes.length;
if (hasHeroes) {
// use first hero that has a display name or avatar url, or whose user ID
// can be looked up as a member of the room
for (const hero of nonFunctionalHeroes) {
// If the hero was from a legacy sync (`/v3/sync`), we will need to look the user ID up in the room
// the display name and avatar URL will not be set.
if (!hero.fromMSC4186) {
// attempt to look up renderable fields from the m.room.member event if it exists
const member = this.getMember(hero.userId);
if (member) {
return member;
}
} else {
// use the Hero supplied values for the room member.
// TODO: It's unfortunate that this function, which clearly only cares about the
// avatar url, returns the entire RoomMember event. We need to fake an event
// to meet this API shape.
const heroMember = new RoomMember(this.roomId, hero.userId);
// set the display name and avatar url
heroMember.setMembershipEvent(
new MatrixEvent({
// ensure it's unique even if we hit the same millisecond
event_id: "$" + this.roomId + hero.userId + new Date().getTime(),
type: EventType.RoomMember,
state_key: hero.userId,
content: {
displayname: hero.displayName,
avatar_url: hero.avatarUrl,
},
}),
);
return heroMember;
}
}
const availableMember = nonFunctionalHeroes
.map((userId) => {
return this.getMember(userId);
.map((hero) => {
return this.getMember(hero.userId);
})
.find((member) => !!member);
if (availableMember) {
@@ -970,8 +1014,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// trust and try falling back to a hero, creating a one-off member for it
if (hasHeroes) {
const availableUser = nonFunctionalHeroes
.map((userId) => {
return this.client.getUser(userId);
.map((hero) => {
return this.client.getUser(hero.userId);
})
.find((user) => !!user);
if (availableUser) {
@@ -1602,6 +1646,24 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
this.emit(RoomEvent.UnreadNotifications);
}
/**
* Set the bump stamp for this room. This can be used for sorting rooms when the timeline
* entries are unknown. Used in MSC4186: Simplified Sliding Sync.
* @param bumpStamp The bump_stamp value from the server
*/
public setBumpStamp(bumpStamp: number): void {
this.bumpStamp = bumpStamp;
}
/**
* Get the bump stamp for this room. This can be used for sorting rooms when the timeline
* entries are unknown. Used in MSC4186: Simplified Sliding Sync.
* @returns The bump stamp for the room, if it exists.
*/
public getBumpStamp(): number | undefined {
return this.bumpStamp;
}
/**
* Set one of the notification counts for this room
* @param type - The type of notification count to set.
@@ -1616,8 +1678,13 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
return this.setUnreadNotificationCount(type, count);
}
/**
* Takes a legacy room summary (/v3/sync as opposed to MSC4186) and updates the room with it.
*
* @param summary - The room summary to update the room with
*/
public setSummary(summary: IRoomSummary): void {
const heroes = summary["m.heroes"];
const heroes = summary["m.heroes"]?.map((h) => ({ userId: h, fromMSC4186: false }));
const joinedCount = summary["m.joined_member_count"];
const invitedCount = summary["m.invited_member_count"];
if (Number.isInteger(joinedCount)) {
@@ -1627,17 +1694,53 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
this.currentState.setInvitedMemberCount(invitedCount!);
}
if (Array.isArray(heroes)) {
// be cautious about trusting server values,
// and make sure heroes doesn't contain our own id
// just to be sure
this.summaryHeroes = heroes.filter((userId) => {
return userId !== this.myUserId;
// filter out ourselves just in case
this.heroes = heroes.filter((h) => {
return h.userId != this.myUserId;
});
}
this.emit(RoomEvent.Summary, summary);
}
/**
* Takes information from the MSC4186 room summary and updates the room with it.
*
* @param heroes - The room's hero members
* @param joinedCount - The number of joined members
* @param invitedCount - The number of invited members
*/
public setMSC4186SummaryData(
heroes: MSC4186Hero[] | undefined,
joinedCount: number | undefined,
invitedCount: number | undefined,
): void {
if (heroes) {
this.heroes = heroes
.filter((h) => h.user_id !== this.myUserId)
.map((h) => ({
userId: h.user_id,
displayName: h.displayname,
avatarUrl: h.avatar_url,
fromMSC4186: true,
}));
}
if (joinedCount !== undefined && Number.isInteger(joinedCount)) {
this.currentState.setJoinedMemberCount(joinedCount);
}
if (invitedCount !== undefined && Number.isInteger(invitedCount)) {
this.currentState.setInvitedMemberCount(invitedCount);
}
// Construct a summary object to emit as the event wants the info in a single object
// more like old-style (/v3/sync) summaries.
this.emit(RoomEvent.Summary, {
"m.heroes": this.heroes ? this.heroes.map((h) => h.userId) : [],
"m.joined_member_count": joinedCount,
"m.invited_member_count": invitedCount,
});
}
/**
* Whether to send encrypted messages to devices within this room.
* @param value - true to blacklist unverified devices, null
@@ -3459,18 +3562,25 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// get service members (e.g. helper bots) for exclusion
const excludedUserIds = this.getFunctionalMembers();
// get members that are NOT ourselves and are actually in the room.
// get members from heroes that are NOT ourselves
let otherNames: string[] = [];
if (this.summaryHeroes) {
// if we have a summary, the member state events should be in the room state
this.summaryHeroes.forEach((userId) => {
if (this.heroes) {
// if we have heroes, use those as the names
this.heroes.forEach((hero) => {
// filter service members
if (excludedUserIds.includes(userId)) {
if (excludedUserIds.includes(hero.userId)) {
inviteJoinCount--;
return;
}
const member = this.getMember(userId);
otherNames.push(member ? member.name : userId);
// If the hero has a display name, use that.
// Otherwise, look their user ID up in the membership and use
// the name from there, or the user ID as a last resort.
if (hero.displayName) {
otherNames.push(hero.displayName);
} else {
const member = this.getMember(hero.userId);
otherNames.push(member ? member.name : hero.userId);
}
});
} else {
let otherMembers = this.currentState.getMembers().filter((m) => {
+17 -4
View File
@@ -54,8 +54,18 @@ interface OidcRegistrationRequestBody {
export const DEVICE_CODE_SCOPE = "urn:ietf:params:oauth:grant-type:device_code";
// Check that URIs have a common base, as per the MSC2966 definition
const urlHasCommonBase = (base: URL, urlStr?: string): boolean => {
if (!urlStr) return false;
const url = new URL(urlStr);
if (url.protocol !== base.protocol) return false;
if (url.hostname !== base.hostname && !url.hostname.endsWith(`.${base.hostname}`)) return false;
return true;
};
/**
* Attempts dynamic registration against the configured registration endpoint
* Attempts dynamic registration against the configured registration endpoint.
* Will ignore any URIs that do not use client_uri as a common base as per the spec.
* @param delegatedAuthConfig - Auth config from {@link discoverAndValidateOIDCIssuerWellKnown}
* @param clientMetadata - The metadata for the client which to register
* @returns Promise<string> resolved with registered clientId
@@ -74,6 +84,8 @@ export const registerOidcClient = async (
throw new Error(OidcError.DynamicRegistrationNotSupported);
}
const commonBase = new URL(clientMetadata.clientUri);
// https://openid.net/specs/openid-connect-registration-1_0.html
const metadata: OidcRegistrationRequestBody = {
client_name: clientMetadata.clientName,
@@ -84,11 +96,12 @@ export const registerOidcClient = async (
id_token_signed_response_alg: "RS256",
token_endpoint_auth_method: "none",
application_type: clientMetadata.applicationType,
logo_uri: clientMetadata.logoUri,
contacts: clientMetadata.contacts,
policy_uri: clientMetadata.policyUri,
tos_uri: clientMetadata.tosUri,
logo_uri: urlHasCommonBase(commonBase, clientMetadata.logoUri) ? clientMetadata.logoUri : undefined,
policy_uri: urlHasCommonBase(commonBase, clientMetadata.policyUri) ? clientMetadata.policyUri : undefined,
tos_uri: urlHasCommonBase(commonBase, clientMetadata.tosUri) ? clientMetadata.tosUri : undefined,
};
const headers = {
"Accept": "application/json",
"Content-Type": "application/json",
+8 -2
View File
@@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type IdTokenClaims, OidcClient, WebStorageStateStore } from "oidc-client-ts";
import { type IdTokenClaims, OidcClient, WebStorageStateStore, ErrorResponse } from "oidc-client-ts";
import { type AccessTokens } from "../http-api/index.ts";
import { type AccessTokens, TokenRefreshLogoutError } from "../http-api/index.ts";
import { generateScope } from "./authorize.ts";
import { discoverAndValidateOIDCIssuerWellKnown } from "./discovery.ts";
import { logger } from "../logger.ts";
@@ -104,6 +104,12 @@ export class OidcTokenRefresher {
try {
const tokens = await this.inflightRefreshRequest;
return tokens;
} catch (e) {
// If we encounter an OIDC error then signal that it should cause a logout by upgrading it to a TokenRefreshLogoutError
if (e instanceof ErrorResponse) {
throw new TokenRefreshLogoutError(e);
}
throw e;
} finally {
this.inflightRefreshRequest = undefined;
}
+25 -16
View File
@@ -308,6 +308,28 @@ export class PushProcessor {
return newRules;
}
/**
* Create a RegExp object for the given glob pattern with a single capture group around the pattern itself, caching the result.
* No cache invalidation is present currently,
* as this will be inherently bounded to the size of the user's own push rules.
* @param pattern - the glob pattern to convert to a RegExp
* @param alignToWordBoundary - whether to align the pattern to word boundaries,
* as specified for `content.body` matches, will use lookaround assertions to ensure the match only includes the pattern
* @param flags - the flags to pass to the RegExp constructor, defaults to case-insensitive
*/
public static getPushRuleGlobRegex(pattern: string, alignToWordBoundary = false, flags = "i"): RegExp {
const [prefix, suffix] = alignToWordBoundary ? ["(?<=^|\\W)", "(?=\\W|$)"] : ["^", "$"];
const cacheKey = `${alignToWordBoundary}-${flags}-${pattern}`;
if (!PushProcessor.cachedGlobToRegex[cacheKey]) {
PushProcessor.cachedGlobToRegex[cacheKey] = new RegExp(
prefix + "(" + globToRegexp(pattern) + ")" + suffix,
flags,
);
}
return PushProcessor.cachedGlobToRegex[cacheKey];
}
/**
* Pre-caches the parsed keys for push rules and cleans out any obsolete cache
* entries. Should be called after push rules are updated.
@@ -567,11 +589,9 @@ export class PushProcessor {
return false;
}
const regex =
cond.key === "content.body"
? this.createCachedRegex("(^|\\W)", cond.pattern, "(\\W|$)")
: this.createCachedRegex("^", cond.pattern, "$");
// Align to word boundary on `content.body` matches, whole string otherwise
// https://spec.matrix.org/v1.13/client-server-api/#conditions-1
const regex = PushProcessor.getPushRuleGlobRegex(cond.pattern, cond.key === "content.body");
return !!val.match(regex);
}
@@ -621,17 +641,6 @@ export class PushProcessor {
);
}
private createCachedRegex(prefix: string, glob: string, suffix: string): RegExp {
if (PushProcessor.cachedGlobToRegex[glob]) {
return PushProcessor.cachedGlobToRegex[glob];
}
PushProcessor.cachedGlobToRegex[glob] = new RegExp(
prefix + globToRegexp(glob) + suffix,
"i", // Case insensitive
);
return PushProcessor.cachedGlobToRegex[glob];
}
/**
* Parse the key into the separate fields to search by splitting on
* unescaped ".", and then removing any escape characters.
@@ -219,6 +219,12 @@ export class OutgoingRequestProcessor {
// we use the full prefix
prefix: "",
// We set a timeout of 60 seconds to guard against requests getting stuck forever and wedging the
// request loop (cf https://github.com/element-hq/element-web/issues/29534).
//
// (XXX: should we do this in the whole of the js-sdk?)
localTimeoutMs: 60000,
};
return await this.http.authedRequest<string>(method, path, queryParams, body, opts);
+44 -12
View File
@@ -1276,6 +1276,24 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
this.checkKeyBackupAndEnable();
}
/**
* Implementation of {@link CryptoApi#disableKeyStorage}.
*/
public async disableKeyStorage(): Promise<void> {
// Get the key backup version we're using
const info = await this.getKeyBackupInfo();
if (info?.version) {
await this.deleteKeyBackupVersion(info.version);
} else {
logger.error("Can't delete key backup version: no version available");
}
// also turn off 4S, since this is also storing keys on the server.
await this.deleteSecretStorage();
await this.dehydratedDeviceManager.delete();
}
/**
* Signs the given object with the current device and current identity (if available).
* As defined in {@link https://spec.matrix.org/v1.8/appendices/#signing-json | Signing JSON}.
@@ -1447,17 +1465,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
// Disable backup, and delete all the backups from the server
await this.backupManager.deleteAllKeyBackupVersions();
// Remove the stored secrets in the secret storage
await this.secretStorage.store("m.cross_signing.master", null);
await this.secretStorage.store("m.cross_signing.self_signing", null);
await this.secretStorage.store("m.cross_signing.user_signing", null);
await this.secretStorage.store("m.megolm_backup.v1", null);
// Remove the recovery key
const defaultKeyId = await this.secretStorage.getDefaultKeyId();
if (defaultKeyId) await this.secretStorage.store(`m.secret_storage.key.${defaultKeyId}`, null);
// Disable the recovery key and the secret storage
await this.secretStorage.setDefaultKeyId(null);
this.deleteSecretStorage();
// Reset the cross-signing keys
await this.crossSigningIdentity.bootstrapCrossSigning({
@@ -1471,6 +1479,24 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
this.logger.debug("resetEncryption: ended");
}
/**
* Removes the secret storage key, default key pointer and all (known) secret storage data
* from the user's account data
*/
private async deleteSecretStorage(): Promise<void> {
// Remove the stored secrets in the secret storage
await this.secretStorage.store("m.cross_signing.master", null);
await this.secretStorage.store("m.cross_signing.self_signing", null);
await this.secretStorage.store("m.cross_signing.user_signing", null);
await this.secretStorage.store("m.megolm_backup.v1", null);
// Remove the recovery key
const defaultKeyId = await this.secretStorage.getDefaultKeyId();
if (defaultKeyId) await this.secretStorage.store(`m.secret_storage.key.${defaultKeyId}`, null);
// Disable the recovery key and the secret storage
await this.secretStorage.setDefaultKeyId(null);
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// SyncCryptoCallbacks implementation
@@ -1609,7 +1635,6 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
/** called by the sync loop after processing each sync.
*
* TODO: figure out something equivalent for sliding sync.
*
* @param syncState - information on the completed sync.
*/
@@ -1621,6 +1646,13 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
});
}
/**
* Implementation of {@link CryptoApi#markAllTrackedUsersAsDirty}.
*/
public async markAllTrackedUsersAsDirty(): Promise<void> {
await this.olmMachine.markAllTrackedUsersAsDirty();
}
/**
* Handle an incoming m.key.verification.request event, received either in-room or in a to-device message.
*
+28 -28
View File
@@ -82,9 +82,16 @@ class ExtensionE2EE implements Extension<ExtensionE2EERequest, ExtensionE2EEResp
return ExtensionState.PreProcess;
}
public onRequest(isInitial: boolean): ExtensionE2EERequest | undefined {
if (!isInitial) {
return undefined;
public async onRequest(isInitial: boolean): Promise<ExtensionE2EERequest> {
if (isInitial) {
// In SSS, the `?pos=` contains the stream position for device list updates.
// If we do not have a `?pos=` (e.g because we forgot it, or because the server
// invalidated our connection) then we MUST invlaidate all device lists because
// the server will not tell us the delta. This will then cause UTDs as we will fail
// to encrypt for new devices. This is an expensive call, so we should
// really really remember `?pos=` wherever possible.
logger.log("ExtensionE2EE: invalidating all device lists due to missing 'pos'");
await this.crypto.markAllTrackedUsersAsDirty();
}
return {
enabled: true, // this is sticky so only send it on the initial request
@@ -134,15 +141,12 @@ class ExtensionToDevice implements Extension<ExtensionToDeviceRequest, Extension
return ExtensionState.PreProcess;
}
public onRequest(isInitial: boolean): ExtensionToDeviceRequest {
const extReq: ExtensionToDeviceRequest = {
public async onRequest(isInitial: boolean): Promise<ExtensionToDeviceRequest> {
return {
since: this.nextBatch !== null ? this.nextBatch : undefined,
limit: 100,
enabled: true,
};
if (isInitial) {
extReq["limit"] = 100;
extReq["enabled"] = true;
}
return extReq;
}
public async onResponse(data: ExtensionToDeviceResponse): Promise<void> {
@@ -216,10 +220,7 @@ class ExtensionAccountData implements Extension<ExtensionAccountDataRequest, Ext
return ExtensionState.PostProcess;
}
public onRequest(isInitial: boolean): ExtensionAccountDataRequest | undefined {
if (!isInitial) {
return undefined;
}
public async onRequest(isInitial: boolean): Promise<ExtensionAccountDataRequest> {
return {
enabled: true,
};
@@ -286,10 +287,7 @@ class ExtensionTyping implements Extension<ExtensionTypingRequest, ExtensionTypi
return ExtensionState.PostProcess;
}
public onRequest(isInitial: boolean): ExtensionTypingRequest | undefined {
if (!isInitial) {
return undefined; // don't send a JSON object for subsequent requests, we don't need to.
}
public async onRequest(isInitial: boolean): Promise<ExtensionTypingRequest> {
return {
enabled: true,
};
@@ -325,13 +323,10 @@ class ExtensionReceipts implements Extension<ExtensionReceiptsRequest, Extension
return ExtensionState.PostProcess;
}
public onRequest(isInitial: boolean): ExtensionReceiptsRequest | undefined {
if (isInitial) {
return {
enabled: true,
};
}
return undefined; // don't send a JSON object for subsequent requests, we don't need to.
public async onRequest(isInitial: boolean): Promise<ExtensionReceiptsRequest> {
return {
enabled: true,
};
}
public async onResponse(data: ExtensionReceiptsResponse): Promise<void> {
@@ -442,6 +437,7 @@ export class SlidingSyncSdk {
}
} else {
this.failCount = 0;
logger.log(`SlidingSyncState.RequestFinished with ${Object.keys(resp?.rooms || []).length} rooms`);
}
break;
}
@@ -580,7 +576,7 @@ export class SlidingSyncSdk {
// TODO: handle threaded / beacon events
if (roomData.initial) {
if (roomData.limited || roomData.initial) {
// we should not know about any of these timeline entries if this is a genuinely new room.
// If we do, then we've effectively done scrollback (e.g requesting timeline_limit: 1 for
// this room, then timeline_limit: 50).
@@ -637,6 +633,9 @@ export class SlidingSyncSdk {
room.setUnreadNotificationCount(NotificationCountType.Highlight, roomData.highlight_count);
}
}
if (roomData.bump_stamp) {
room.setBumpStamp(roomData.bump_stamp);
}
if (Number.isInteger(roomData.invited_count)) {
room.currentState.setInvitedMemberCount(roomData.invited_count!);
@@ -656,11 +655,10 @@ export class SlidingSyncSdk {
inviteStateEvents.forEach((e) => {
this.client.emit(ClientEvent.Event, e);
});
room.updateMyMembership(KnownMembership.Invite);
return;
}
if (roomData.initial) {
if (roomData.limited) {
// set the back-pagination token. Do this *before* adding any
// events so that clients can start back-paginating.
room.getLiveTimeline().setPaginationToken(roomData.prev_batch ?? null, EventTimeline.BACKWARDS);
@@ -728,6 +726,8 @@ export class SlidingSyncSdk {
// synchronous execution prior to emitting SlidingSyncState.Complete
room.updateMyMembership(KnownMembership.Join);
room.setMSC4186SummaryData(roomData.heroes, roomData.joined_count, roomData.invited_count);
room.recalculate();
if (roomData.initial) {
client.store.storeRoom(room);
+38 -329
View File
@@ -1,5 +1,5 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2022-2024 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.
@@ -18,7 +18,7 @@ import { logger } from "./logger.ts";
import { type MatrixClient } from "./client.ts";
import { type IRoomEvent, type IStateEvent } from "./sync-accumulator.ts";
import { TypedEventEmitter } from "./models/typed-event-emitter.ts";
import { sleep, type IDeferred, defer } from "./utils.ts";
import { sleep } from "./utils.ts";
import { type HTTPError } from "./http-api/index.ts";
// /sync requests allow you to set a timeout= but the request may continue
@@ -82,10 +82,23 @@ export interface MSC3575SlidingSyncRequest {
clientTimeout?: number;
}
/**
* New format of hero introduced in MSC4186 with display name and avatar URL
* in addition to just user_id (as it is on the wire, with underscores)
* as opposed to Hero in room-summary.ts which has fields in camelCase
* (and also a flag to note what format the hero came from).
*/
export interface MSC4186Hero {
user_id: string;
displayname?: string;
avatar_url?: string;
}
export interface MSC3575RoomData {
name: string;
required_state: IStateEvent[];
timeline: (IRoomEvent | IStateEvent)[];
heroes?: MSC4186Hero[];
notification_count?: number;
highlight_count?: number;
joined_count?: number;
@@ -96,41 +109,13 @@ export interface MSC3575RoomData {
is_dm?: boolean;
prev_batch?: string;
num_live?: number;
bump_stamp?: number;
}
interface ListResponse {
count: number;
ops: Operation[];
}
interface BaseOperation {
op: string;
}
interface DeleteOperation extends BaseOperation {
op: "DELETE";
index: number;
}
interface InsertOperation extends BaseOperation {
op: "INSERT";
index: number;
room_id: string;
}
interface InvalidateOperation extends BaseOperation {
op: "INVALIDATE";
range: [number, number];
}
interface SyncOperation extends BaseOperation {
op: "SYNC";
range: [number, number];
room_ids: string[];
}
type Operation = DeleteOperation | InsertOperation | InvalidateOperation | SyncOperation;
/**
* A complete Sliding Sync response
*/
@@ -163,7 +148,6 @@ class SlidingList {
private isModified?: boolean;
// returned data
public roomIndexToRoomId: Record<number, string> = {};
public joinedCount = 0;
/**
@@ -204,9 +188,6 @@ class SlidingList {
// reset values as the join count may be very different (if filters changed) including the rooms
// (e.g. sort orders or sliding window ranges changed)
// the constantly changing sliding window ranges. Not an array for performance reasons
// E.g. tracking ranges 0-99, 500-599, we don't want to have a 600 element array
this.roomIndexToRoomId = {};
// the total number of joined rooms according to the server, always >= len(roomIndexToRoomId)
this.joinedCount = 0;
}
@@ -226,26 +207,6 @@ class SlidingList {
}
return list;
}
/**
* Check if a given index is within the list range. This is required even though the /sync API
* provides explicit updates with index positions because of the following situation:
* 0 1 2 3 4 5 6 7 8 indexes
* a b c d e f COMMANDS: SYNC 0 2 a b c; SYNC 6 8 d e f;
* a b c d _ f COMMAND: DELETE 7;
* e a b c d f COMMAND: INSERT 0 e;
* c=3 is wrong as we are not tracking it, ergo we need to see if `i` is in range else drop it
* @param i - The index to check
* @returns True if the index is within a sliding window
*/
public isIndexInRange(i: number): boolean {
for (const r of this.list.ranges) {
if (r[0] <= i && i <= r[1]) {
return true;
}
}
return false;
}
}
/**
@@ -274,10 +235,10 @@ export interface Extension<Req extends object, Res extends object> {
/**
* A function which is called when the request JSON is being formed.
* Returns the data to insert under this key.
* @param isInitial - True when this is part of the initial request (send sticky params)
* @param isInitial - True when this is part of the initial request.
* @returns The request JSON to send.
*/
onRequest(isInitial: boolean): Req | undefined;
onRequest(isInitial: boolean): Promise<Req>;
/**
* A function which is called when there is response JSON under this extension.
* @param data - The response JSON under the extension name.
@@ -295,12 +256,10 @@ export interface Extension<Req extends object, Res extends object> {
* of information when processing sync responses.
* - RoomData: concerns rooms, useful for SlidingSyncSdk to update its knowledge of rooms.
* - Lifecycle: concerns callbacks at various well-defined points in the sync process.
* - List: concerns lists, useful for UI layers to re-render room lists.
* Specifically, the order of event invocation is:
* - Lifecycle (state=RequestFinished)
* - RoomData (N times)
* - Lifecycle (state=Complete)
* - List (at most once per list)
*/
export enum SlidingSyncEvent {
/**
@@ -313,16 +272,9 @@ export enum SlidingSyncEvent {
* - SlidingSyncState.RequestFinished: Fires after we receive a valid response but before the
* response has been processed. Perform any pre-process steps here. If there was a problem syncing,
* `err` will be set (e.g network errors).
* - SlidingSyncState.Complete: Fires after all SlidingSyncEvent.RoomData have been fired but before
* SlidingSyncEvent.List.
* - SlidingSyncState.Complete: Fires after the response has been processed.
*/
Lifecycle = "SlidingSync.Lifecycle",
/**
* This event fires whenever there has been a change to this list index. It fires exactly once
* per list, even if there were multiple operations for the list.
* It fires AFTER Lifecycle and RoomData events.
*/
List = "SlidingSync.List",
}
export type SlidingSyncEventHandlerMap = {
@@ -332,7 +284,6 @@ export type SlidingSyncEventHandlerMap = {
resp: MSC3575SlidingSyncResponse | null,
err?: Error,
) => void;
[SlidingSyncEvent.List]: (listKey: string, joinedCount: number, roomIndexToRoomId: Record<number, string>) => void;
};
/**
@@ -347,11 +298,6 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
private terminated = false;
// flag set when resend() is called because we cannot rely on detecting AbortError in JS SDK :(
private needsResend = false;
// the txn_id to send with the next request.
private txnId: string | null = null;
// a list (in chronological order of when they were sent) of objects containing the txn ID and
// a defer to resolve/reject depending on whether they were successfully sent or not.
private txnIdDefers: (IDeferred<string> & { txnId: string })[] = [];
// map of extension name to req/resp handler
private extensions: Record<string, Extension<any, any>> = {};
@@ -426,14 +372,13 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
* @param key - The list key
* @returns The list data which contains the rooms in this list
*/
public getListData(key: string): { joinedCount: number; roomIndexToRoomId: Record<number, string> } | null {
public getListData(key: string): { joinedCount: number } | null {
const data = this.lists.get(key);
if (!data) {
return null;
}
return {
joinedCount: data.joinedCount,
roomIndexToRoomId: Object.assign({}, data.roomIndexToRoomId),
};
}
@@ -461,13 +406,13 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
* (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
* immediately after sending, in which case the action will be applied in the subsequent request)
*/
public setListRanges(key: string, ranges: number[][]): Promise<string> {
public setListRanges(key: string, ranges: number[][]): void {
const list = this.lists.get(key);
if (!list) {
return Promise.reject(new Error("no list with key " + key));
throw new Error("no list with key " + key);
}
list.updateListRange(ranges);
return this.resend();
this.resend();
}
/**
@@ -479,7 +424,7 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
* (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
* immediately after sending, in which case the action will be applied in the subsequent request)
*/
public setList(key: string, list: MSC3575List): Promise<string> {
public setList(key: string, list: MSC3575List): void {
const existingList = this.lists.get(key);
if (existingList) {
existingList.replaceList(list);
@@ -488,7 +433,7 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
this.lists.set(key, new SlidingList(list));
}
this.listModifiedCount += 1;
return this.resend();
this.resend();
}
/**
@@ -504,27 +449,21 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
* /sync request to resend new subscriptions. If the /sync stream has not started, this will
* prepare the room subscriptions for when start() is called.
* @param s - The new desired room subscriptions.
* @returns A promise which resolves to the transaction ID when it has been received down sync
* (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
* immediately after sending, in which case the action will be applied in the subsequent request)
*/
public modifyRoomSubscriptions(s: Set<string>): Promise<string> {
public modifyRoomSubscriptions(s: Set<string>): void {
this.desiredRoomSubscriptions = s;
return this.resend();
this.resend();
}
/**
* Modify which events to retrieve for room subscriptions. Invalidates all room subscriptions
* such that they will be sent up afresh.
* @param rs - The new room subscription fields to fetch.
* @returns A promise which resolves to the transaction ID when it has been received down sync
* (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
* immediately after sending, in which case the action will be applied in the subsequent request)
*/
public modifyRoomSubscriptionInfo(rs: MSC3575RoomSubscription): Promise<string> {
public modifyRoomSubscriptionInfo(rs: MSC3575RoomSubscription): void {
this.roomSubscriptionInfo = rs;
this.confirmedRoomSubscriptions = new Set<string>();
return this.resend();
this.resend();
}
/**
@@ -538,11 +477,11 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
this.extensions[ext.name()] = ext;
}
private getExtensionRequest(isInitial: boolean): Record<string, object | undefined> {
private async getExtensionRequest(isInitial: boolean): Promise<Record<string, object | undefined>> {
const ext: Record<string, object | undefined> = {};
Object.keys(this.extensions).forEach((extName) => {
ext[extName] = this.extensions[extName].onRequest(isInitial);
});
for (const extName in this.extensions) {
ext[extName] = await this.extensions[extName].onRequest(isInitial);
}
return ext;
}
@@ -595,203 +534,13 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
this.emit(SlidingSyncEvent.Lifecycle, state, resp, err);
}
private shiftRight(listKey: string, hi: number, low: number): void {
const list = this.lists.get(listKey);
if (!list) {
return;
}
// l h
// 0,1,2,3,4 <- before
// 0,1,2,2,3 <- after, hi is deleted and low is duplicated
for (let i = hi; i > low; i--) {
if (list.isIndexInRange(i)) {
list.roomIndexToRoomId[i] = list.roomIndexToRoomId[i - 1];
}
}
}
private shiftLeft(listKey: string, hi: number, low: number): void {
const list = this.lists.get(listKey);
if (!list) {
return;
}
// l h
// 0,1,2,3,4 <- before
// 0,1,3,4,4 <- after, low is deleted and hi is duplicated
for (let i = low; i < hi; i++) {
if (list.isIndexInRange(i)) {
list.roomIndexToRoomId[i] = list.roomIndexToRoomId[i + 1];
}
}
}
private removeEntry(listKey: string, index: number): void {
const list = this.lists.get(listKey);
if (!list) {
return;
}
// work out the max index
let max = -1;
for (const n in list.roomIndexToRoomId) {
if (Number(n) > max) {
max = Number(n);
}
}
if (max < 0 || index > max) {
return;
}
// Everything higher than the gap needs to be shifted left.
this.shiftLeft(listKey, max, index);
delete list.roomIndexToRoomId[max];
}
private addEntry(listKey: string, index: number): void {
const list = this.lists.get(listKey);
if (!list) {
return;
}
// work out the max index
let max = -1;
for (const n in list.roomIndexToRoomId) {
if (Number(n) > max) {
max = Number(n);
}
}
if (max < 0 || index > max) {
return;
}
// Everything higher than the gap needs to be shifted right, +1 so we don't delete the highest element
this.shiftRight(listKey, max + 1, index);
}
private processListOps(list: ListResponse, listKey: string): void {
let gapIndex = -1;
const listData = this.lists.get(listKey);
if (!listData) {
return;
}
list.ops.forEach((op: Operation) => {
if (!listData) {
return;
}
switch (op.op) {
case "DELETE": {
logger.debug("DELETE", listKey, op.index, ";");
delete listData.roomIndexToRoomId[op.index];
if (gapIndex !== -1) {
// we already have a DELETE operation to process, so process it.
this.removeEntry(listKey, gapIndex);
}
gapIndex = op.index;
break;
}
case "INSERT": {
logger.debug("INSERT", listKey, op.index, op.room_id, ";");
if (listData.roomIndexToRoomId[op.index]) {
// something is in this space, shift items out of the way
if (gapIndex < 0) {
// we haven't been told where to shift from, so make way for a new room entry.
this.addEntry(listKey, op.index);
} else if (gapIndex > op.index) {
// the gap is further down the list, shift every element to the right
// starting at the gap so we can just shift each element in turn:
// [A,B,C,_] gapIndex=3, op.index=0
// [A,B,C,C] i=3
// [A,B,B,C] i=2
// [A,A,B,C] i=1
// Terminate. We'll assign into op.index next.
this.shiftRight(listKey, gapIndex, op.index);
} else if (gapIndex < op.index) {
// the gap is further up the list, shift every element to the left
// starting at the gap so we can just shift each element in turn
this.shiftLeft(listKey, op.index, gapIndex);
}
}
// forget the gap, we don't need it anymore. This is outside the check for
// a room being present in this index position because INSERTs always universally
// forget the gap, not conditionally based on the presence of a room in the INSERT
// position. Without this, DELETE 0; INSERT 0; would do the wrong thing.
gapIndex = -1;
listData.roomIndexToRoomId[op.index] = op.room_id;
break;
}
case "INVALIDATE": {
const startIndex = op.range[0];
for (let i = startIndex; i <= op.range[1]; i++) {
delete listData.roomIndexToRoomId[i];
}
logger.debug("INVALIDATE", listKey, op.range[0], op.range[1], ";");
break;
}
case "SYNC": {
const startIndex = op.range[0];
for (let i = startIndex; i <= op.range[1]; i++) {
const roomId = op.room_ids[i - startIndex];
if (!roomId) {
break; // we are at the end of list
}
listData.roomIndexToRoomId[i] = roomId;
}
logger.debug("SYNC", listKey, op.range[0], op.range[1], (op.room_ids || []).join(" "), ";");
break;
}
}
});
if (gapIndex !== -1) {
// we already have a DELETE operation to process, so process it
// Everything higher than the gap needs to be shifted left.
this.removeEntry(listKey, gapIndex);
}
}
/**
* Resend a Sliding Sync request. Used when something has changed in the request. Resolves with
* the transaction ID of this request on success. Rejects with the transaction ID of this request
* on failure.
* Resend a Sliding Sync request. Used when something has changed in the request.
*/
public resend(): Promise<string> {
if (this.needsResend && this.txnIdDefers.length > 0) {
// we already have a resend queued, so just return the same promise
return this.txnIdDefers[this.txnIdDefers.length - 1].promise;
}
public resend(): void {
this.needsResend = true;
this.txnId = this.client.makeTxnId();
const d = defer<string>();
this.txnIdDefers.push({
...d,
txnId: this.txnId,
});
this.abortController?.abort();
this.abortController = new AbortController();
return d.promise;
}
private resolveTransactionDefers(txnId?: string): void {
if (!txnId) {
return;
}
// find the matching index
let txnIndex = -1;
for (let i = 0; i < this.txnIdDefers.length; i++) {
if (this.txnIdDefers[i].txnId === txnId) {
txnIndex = i;
break;
}
}
if (txnIndex === -1) {
// this shouldn't happen; we shouldn't be seeing txn_ids for things we don't know about,
// whine about it.
logger.warn(`resolveTransactionDefers: seen ${txnId} but it isn't a pending txn, ignoring.`);
return;
}
// This list is sorted in time, so if the input txnId ACKs in the middle of this array,
// then everything before it that hasn't been ACKed yet never will and we should reject them.
for (let i = 0; i < txnIndex; i++) {
this.txnIdDefers[i].reject(this.txnIdDefers[i].txnId);
}
this.txnIdDefers[txnIndex].resolve(txnId);
// clear out settled promises, including the one we resolved.
this.txnIdDefers = this.txnIdDefers.slice(txnIndex + 1);
}
/**
@@ -802,7 +551,6 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
this.abortController?.abort();
// remove all listeners so things can be GC'd
this.removeAllListeners(SlidingSyncEvent.Lifecycle);
this.removeAllListeners(SlidingSyncEvent.List);
this.removeAllListeners(SlidingSyncEvent.RoomData);
}
@@ -811,20 +559,13 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
*/
private resetup(): void {
logger.warn("SlidingSync: resetting connection info");
// any pending txn ID defers will be forgotten already by the server, so clear them out
this.txnIdDefers.forEach((d) => {
d.reject(d.txnId);
});
this.txnIdDefers = [];
// resend sticky params and de-confirm all subscriptions
this.lists.forEach((l) => {
l.setModified(true);
});
this.confirmedRoomSubscriptions = new Set<string>(); // leave desired ones alone though!
// reset the connection as we might be wedged
this.needsResend = true;
this.abortController?.abort();
this.abortController = new AbortController();
this.resend();
}
/**
@@ -836,20 +577,18 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
let currentPos: string | undefined;
while (!this.terminated) {
this.needsResend = false;
let doNotUpdateList = false;
let resp: MSC3575SlidingSyncResponse | undefined;
try {
const listModifiedCount = this.listModifiedCount;
const reqLists: Record<string, MSC3575List> = {};
this.lists.forEach((l: SlidingList, key: string) => {
reqLists[key] = l.getList(false);
reqLists[key] = l.getList(true);
});
const reqBody: MSC3575SlidingSyncRequest = {
lists: reqLists,
pos: currentPos,
timeout: this.timeoutMS,
clientTimeout: this.timeoutMS + BUFFER_PERIOD_MS,
extensions: this.getExtensionRequest(currentPos === undefined),
extensions: await this.getExtensionRequest(currentPos === undefined),
};
// check if we are (un)subscribing to a room and modify request this one time for it
const newSubscriptions = difference(this.desiredRoomSubscriptions, this.confirmedRoomSubscriptions);
@@ -868,10 +607,6 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
reqBody.room_subscriptions[roomId] = sub;
}
}
if (this.txnId) {
reqBody.txn_id = this.txnId;
this.txnId = null;
}
this.pendingReq = this.client.slidingSync(reqBody, this.proxyBaseUrl, this.abortController.signal);
resp = await this.pendingReq;
currentPos = resp.pos;
@@ -882,13 +617,6 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
for (const roomId of unsubscriptions) {
this.confirmedRoomSubscriptions.delete(roomId);
}
if (listModifiedCount !== this.listModifiedCount) {
// the lists have been modified whilst we were waiting for 'await' to return, but the abort()
// call did nothing. It is NOT SAFE to modify the list array now. We'll process the response but
// not update list pointers.
logger.debug("list modified during await call, not updating list");
doNotUpdateList = true;
}
// mark all these lists as having been sent as sticky so we don't keep sending sticky params
this.lists.forEach((l) => {
l.setModified(false);
@@ -931,27 +659,8 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
await this.invokeRoomDataListeners(roomId, resp!.rooms[roomId]);
}
const listKeysWithUpdates: Set<string> = new Set();
if (!doNotUpdateList) {
for (const [key, list] of Object.entries(resp.lists)) {
list.ops = list.ops ?? [];
if (list.ops.length > 0) {
listKeysWithUpdates.add(key);
}
this.processListOps(list, key);
}
}
this.invokeLifecycleListeners(SlidingSyncState.Complete, resp);
await this.onPostExtensionsResponse(resp.extensions);
listKeysWithUpdates.forEach((listKey: string) => {
const list = this.lists.get(listKey);
if (!list) {
return;
}
this.emit(SlidingSyncEvent.List, listKey, list.joinedCount, Object.assign({}, list.roomIndexToRoomId));
});
this.resolveTransactionDefers(resp.txn_id);
}
}
}
+17
View File
@@ -405,6 +405,23 @@ export async function logDuration<T>(logger: BaseLogger, name: string, block: ()
}
}
/**
* Utility to log the duration of a synchronous block.
*
* @param logger - The logger to log to.
* @param name - The name of the operation.
* @param block - The block to execute.
*/
export function logDurationSync<T>(logger: BaseLogger, name: string, block: () => T): T {
const start = Date.now();
try {
return block();
} finally {
const end = Date.now();
logger.debug(`[Perf]: ${name} took ${end - start}ms`);
}
}
/**
* Promise/async version of {@link setImmediate}.
*
+393 -223
View File
File diff suppressed because it is too large Load Diff